mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-02 13:50:26 +00:00
Use sys.argv and sys.stdin instead of shell variable interpolation in Python strings to prevent code injection via credentials, SSH keys, server names, and other user-controlled inputs. RamNode fixes: - _get_ramnode_token: credentials via sys.argv instead of string interpolation - Config file read: use sys.argv[1] for file path (matches other providers) - Config file save: use sys.argv for all values - ramnode_check_ssh_key: key_name via sys.argv - ramnode_register_ssh_key: public key via stdin, name via sys.argv - create_server: all parameters via sys.argv Netcup fixes: - netcup_get_session: use python3+json.dumps instead of unquoted heredoc - netcup_api: use python3+json.dumps for action parameter - Config file read: use sys.argv[1] for file path - Config file save: use python3+sys.argv instead of unquoted heredoc - create_server: all parameters via sys.argv Agent: security-auditor Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
536 lines
15 KiB
Bash
Executable file
536 lines
15 KiB
Bash
Executable file
#!/bin/bash
|
|
set -eo pipefail
|
|
# Common bash functions for RamNode Cloud spawn scripts
|
|
|
|
# ============================================================
|
|
# Provider-agnostic functions
|
|
# ============================================================
|
|
|
|
# Source shared provider-agnostic functions (local or remote fallback)
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
|
if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../shared/common.sh" ]]; then
|
|
source "$SCRIPT_DIR/../../shared/common.sh"
|
|
else
|
|
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/common.sh)"
|
|
fi
|
|
|
|
# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh
|
|
|
|
# ============================================================
|
|
# RamNode Cloud specific functions (OpenStack API)
|
|
# ============================================================
|
|
|
|
readonly RAMNODE_API_BASE="https://openstack.ramnode.com"
|
|
readonly RAMNODE_IDENTITY_API="${RAMNODE_API_BASE}:5000/v3"
|
|
readonly RAMNODE_COMPUTE_API="${RAMNODE_API_BASE}:8774/v2.1"
|
|
readonly RAMNODE_NETWORK_API="${RAMNODE_API_BASE}:9696/v2.0"
|
|
# SSH_OPTS is now defined in shared/common.sh
|
|
|
|
# Get auth token from RamNode OpenStack API
|
|
_get_ramnode_token() {
|
|
local username="$1"
|
|
local password="$2"
|
|
local project_id="$3"
|
|
|
|
local auth_body
|
|
auth_body=$(python3 -c "
|
|
import json, sys
|
|
username, password, project_id = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
body = {
|
|
'auth': {
|
|
'identity': {
|
|
'methods': ['password'],
|
|
'password': {
|
|
'user': {
|
|
'name': username,
|
|
'domain': {'id': 'default'},
|
|
'password': password
|
|
}
|
|
}
|
|
},
|
|
'scope': {
|
|
'project': {
|
|
'id': project_id,
|
|
'domain': {'id': 'default'}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
print(json.dumps(body))
|
|
" "$username" "$password" "$project_id")
|
|
|
|
local response
|
|
response=$(curl -fsSL -X POST \
|
|
"$RAMNODE_IDENTITY_API/auth/tokens" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$auth_body" \
|
|
-i 2>/dev/null || echo "")
|
|
|
|
if [[ -z "$response" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
# Extract token from X-Subject-Token header
|
|
echo "$response" | grep -i '^X-Subject-Token:' | cut -d' ' -f2 | tr -d '\r\n'
|
|
}
|
|
|
|
# Centralized curl wrapper for RamNode Compute API
|
|
ramnode_compute_api() {
|
|
local method="$1"
|
|
local endpoint="$2"
|
|
local body="${3:-}"
|
|
|
|
local url="${RAMNODE_COMPUTE_API}${endpoint}"
|
|
|
|
if [[ -z "${RAMNODE_AUTH_TOKEN:-}" ]]; then
|
|
log_error "RAMNODE_AUTH_TOKEN not set"
|
|
return 1
|
|
fi
|
|
|
|
local curl_opts=(-fsSL -X "$method" "$url")
|
|
curl_opts+=(-H "X-Auth-Token: ${RAMNODE_AUTH_TOKEN}")
|
|
curl_opts+=(-H "Content-Type: application/json")
|
|
|
|
if [[ -n "$body" ]]; then
|
|
curl_opts+=(-d "$body")
|
|
fi
|
|
|
|
curl "${curl_opts[@]}" 2>/dev/null || echo '{"error": "API call failed"}'
|
|
}
|
|
|
|
# Test RamNode credentials
|
|
test_ramnode_credentials() {
|
|
local token
|
|
token=$(_get_ramnode_token "${RAMNODE_USERNAME}" "${RAMNODE_PASSWORD}" "${RAMNODE_PROJECT_ID}")
|
|
|
|
if [[ -z "$token" ]]; then
|
|
log_error "Authentication failed"
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
log_error " 1. Get your OpenStack credentials from RamNode Cloud Control Panel"
|
|
log_error " 2. Go to: https://manage.ramnode.com/ → Cloud → API Users"
|
|
log_error " 3. Create or use existing API user and get credentials"
|
|
log_error " 4. Set RAMNODE_USERNAME, RAMNODE_PASSWORD, and RAMNODE_PROJECT_ID"
|
|
return 1
|
|
fi
|
|
|
|
# Store token for subsequent API calls
|
|
export RAMNODE_AUTH_TOKEN="$token"
|
|
return 0
|
|
}
|
|
|
|
# Ensure RamNode credentials are available
|
|
ensure_ramnode_credentials() {
|
|
# Check for required environment variables
|
|
if [[ -n "${RAMNODE_USERNAME:-}" && -n "${RAMNODE_PASSWORD:-}" && -n "${RAMNODE_PROJECT_ID:-}" ]]; then
|
|
if test_ramnode_credentials; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Try to load from config file
|
|
local config_file="$HOME/.config/spawn/ramnode.json"
|
|
if [[ -f "$config_file" ]]; then
|
|
log_info "Loading RamNode credentials from $config_file..."
|
|
RAMNODE_USERNAME=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('username', ''))" "$config_file" 2>/dev/null || echo "")
|
|
RAMNODE_PASSWORD=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('password', ''))" "$config_file" 2>/dev/null || echo "")
|
|
RAMNODE_PROJECT_ID=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('project_id', ''))" "$config_file" 2>/dev/null || echo "")
|
|
|
|
if [[ -n "$RAMNODE_USERNAME" && -n "$RAMNODE_PASSWORD" && -n "$RAMNODE_PROJECT_ID" ]]; then
|
|
export RAMNODE_USERNAME RAMNODE_PASSWORD RAMNODE_PROJECT_ID
|
|
if test_ramnode_credentials; then
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Prompt for credentials
|
|
log_warn "RamNode OpenStack credentials not found"
|
|
log_info ""
|
|
log_info "Get credentials from: https://manage.ramnode.com/ → Cloud → API Users"
|
|
log_info ""
|
|
|
|
RAMNODE_USERNAME=$(safe_read "Enter RamNode username: ") || return 1
|
|
RAMNODE_PASSWORD=$(safe_read "Enter RamNode password: ") || return 1
|
|
RAMNODE_PROJECT_ID=$(safe_read "Enter RamNode project ID: ") || return 1
|
|
|
|
export RAMNODE_USERNAME RAMNODE_PASSWORD RAMNODE_PROJECT_ID
|
|
|
|
if ! test_ramnode_credentials; then
|
|
log_error "Invalid credentials"
|
|
return 1
|
|
fi
|
|
|
|
# Save to config file
|
|
log_info "Saving credentials to $config_file..."
|
|
mkdir -p "$(dirname "$config_file")"
|
|
python3 -c "
|
|
import json, sys
|
|
config = {
|
|
'username': sys.argv[2],
|
|
'password': sys.argv[3],
|
|
'project_id': sys.argv[4]
|
|
}
|
|
with open(sys.argv[1], 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
" "$config_file" "$RAMNODE_USERNAME" "$RAMNODE_PASSWORD" "$RAMNODE_PROJECT_ID"
|
|
chmod 600 "$config_file"
|
|
|
|
return 0
|
|
}
|
|
|
|
# Check if SSH key is registered with RamNode
|
|
ramnode_check_ssh_key() {
|
|
local fingerprint="$1"
|
|
|
|
# OpenStack uses different fingerprint format - we'll check by name instead
|
|
local key_name="spawn-$(whoami)-$(hostname)"
|
|
local response
|
|
response=$(ramnode_compute_api GET "/os-keypairs")
|
|
|
|
echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
keypairs = data.get('keypairs', [])
|
|
key_name = sys.argv[1]
|
|
for kp in keypairs:
|
|
if kp.get('keypair', {}).get('name') == key_name:
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
" "$key_name" && return 0 || return 1
|
|
}
|
|
|
|
# Register SSH key with RamNode
|
|
ramnode_register_ssh_key() {
|
|
local key_name="$1"
|
|
local pub_path="$2"
|
|
|
|
local body
|
|
body=$(python3 -c "
|
|
import json, sys
|
|
body = {
|
|
'keypair': {
|
|
'name': sys.argv[1],
|
|
'public_key': sys.stdin.read().strip()
|
|
}
|
|
}
|
|
print(json.dumps(body))
|
|
" "$key_name" < "$pub_path")
|
|
|
|
local response
|
|
response=$(ramnode_compute_api POST "/os-keypairs" "$body")
|
|
|
|
if echo "$response" | grep -q '"error"'; then
|
|
local error_msg
|
|
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('error',{}).get('message','Unknown error'))" 2>/dev/null || echo "$response")
|
|
log_error "API Error: $error_msg"
|
|
log_error ""
|
|
log_error "Common causes:"
|
|
log_error " - SSH key already registered with this name"
|
|
log_error " - Invalid SSH key format"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Ensure SSH key exists locally and is registered with RamNode
|
|
ensure_ssh_key() {
|
|
ensure_ssh_key_with_provider ramnode_check_ssh_key ramnode_register_ssh_key "RamNode"
|
|
}
|
|
|
|
# Get server name from env var or prompt
|
|
get_server_name() {
|
|
local server_name
|
|
server_name=$(get_resource_name "RAMNODE_SERVER_NAME" "Enter server name: ") || return 1
|
|
|
|
if ! validate_server_name "$server_name"; then
|
|
return 1
|
|
fi
|
|
|
|
echo "$server_name"
|
|
}
|
|
|
|
# List available flavors (instance types)
|
|
_list_flavors() {
|
|
local response
|
|
response=$(ramnode_compute_api GET "/flavors/detail")
|
|
|
|
echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
flavors = data.get('flavors', [])
|
|
# Sort by vcpus, then ram
|
|
flavors.sort(key=lambda f: (f['vcpus'], f['ram']))
|
|
for f in flavors:
|
|
vcpus = f['vcpus']
|
|
ram_mb = f['ram']
|
|
ram_gb = ram_mb / 1024
|
|
disk_gb = f['disk']
|
|
name = f['name']
|
|
print(f'{name}|{vcpus} vCPU|{ram_gb:.1f} GB RAM|{disk_gb} GB disk')
|
|
"
|
|
}
|
|
|
|
# Interactive flavor picker
|
|
_pick_flavor() {
|
|
if [[ -n "${RAMNODE_FLAVOR:-}" ]]; then
|
|
echo "$RAMNODE_FLAVOR"
|
|
return
|
|
fi
|
|
|
|
log_info "Fetching available instance types..."
|
|
local flavors
|
|
flavors=$(_list_flavors)
|
|
|
|
if [[ -z "$flavors" ]]; then
|
|
log_warn "Could not fetch flavors, using default: 1GB"
|
|
echo "1GB"
|
|
return
|
|
fi
|
|
|
|
log_info "Available instance types:"
|
|
local i=1
|
|
local names=()
|
|
while IFS='|' read -r name cores ram disk; do
|
|
printf " %2d) %-12s %-8s %-12s %s\n" "$i" "$name" "$cores" "$ram" "$disk" >&2
|
|
names+=("$name")
|
|
i=$((i + 1))
|
|
done <<< "$flavors"
|
|
|
|
local choice
|
|
printf "\n" >&2
|
|
choice=$(safe_read "Select instance type [1]: ") || choice=""
|
|
choice="${choice:-1}"
|
|
|
|
if [[ "$choice" -ge 1 && "$choice" -le "${#names[@]}" ]] 2>/dev/null; then
|
|
echo "${names[$((choice - 1))]}"
|
|
else
|
|
log_warn "Invalid choice, using default: 1GB"
|
|
echo "1GB"
|
|
fi
|
|
}
|
|
|
|
# List available images
|
|
_list_images() {
|
|
local response
|
|
response=$(ramnode_compute_api GET "/images/detail")
|
|
|
|
echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
images = data.get('images', [])
|
|
# Filter for Ubuntu 24.04
|
|
ubuntu_images = [img for img in images if 'ubuntu' in img.get('name', '').lower() and '24.04' in img.get('name', '')]
|
|
if ubuntu_images:
|
|
# Use first Ubuntu 24.04 image
|
|
print(ubuntu_images[0]['id'])
|
|
elif images:
|
|
# Fallback to first image
|
|
print(images[0]['id'])
|
|
"
|
|
}
|
|
|
|
# Get default network ID
|
|
_get_network_id() {
|
|
local response
|
|
response=$(curl -fsSL -X GET \
|
|
"$RAMNODE_NETWORK_API/networks" \
|
|
-H "X-Auth-Token: ${RAMNODE_AUTH_TOKEN}" \
|
|
-H "Content-Type: application/json" 2>/dev/null || echo '{"networks":[]}')
|
|
|
|
echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
networks = data.get('networks', [])
|
|
if networks:
|
|
print(networks[0]['id'])
|
|
"
|
|
}
|
|
|
|
# Create a RamNode server
|
|
create_server() {
|
|
local name="$1"
|
|
|
|
# Get flavor
|
|
local flavor
|
|
flavor=$(_pick_flavor)
|
|
|
|
# Get image ID
|
|
log_info "Fetching Ubuntu 24.04 image..."
|
|
local image_id
|
|
image_id=$(_list_images)
|
|
|
|
if [[ -z "$image_id" ]]; then
|
|
log_error "Could not find Ubuntu 24.04 image"
|
|
return 1
|
|
fi
|
|
|
|
# Get network ID
|
|
local network_id
|
|
network_id=$(_get_network_id)
|
|
|
|
# Get SSH key name
|
|
local key_name="spawn-$(whoami)-$(hostname)"
|
|
|
|
# Get cloud-init userdata
|
|
local userdata
|
|
userdata=$(get_cloud_init_userdata | base64 -w 0 || get_cloud_init_userdata | base64)
|
|
|
|
log_warn "Creating RamNode instance '$name' (flavor: $flavor)..."
|
|
|
|
# Build request body
|
|
local body
|
|
body=$(python3 -c "
|
|
import json, sys
|
|
name, flavor, image_id, key_name, userdata, network_id = sys.argv[1:7]
|
|
body = {
|
|
'server': {
|
|
'name': name,
|
|
'flavorRef': flavor,
|
|
'imageRef': image_id,
|
|
'key_name': key_name,
|
|
'user_data': userdata
|
|
}
|
|
}
|
|
if network_id:
|
|
body['server']['networks'] = [{'uuid': network_id}]
|
|
print(json.dumps(body))
|
|
" "$name" "$flavor" "$image_id" "$key_name" "$userdata" "${network_id:-}")
|
|
|
|
local response
|
|
response=$(ramnode_compute_api POST "/servers" "$body")
|
|
|
|
if echo "$response" | grep -q '"error"'; then
|
|
log_error "Failed to create RamNode server"
|
|
local error_msg
|
|
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('error',{}).get('message','Unknown error'))" 2>/dev/null || echo "$response")
|
|
log_error "API Error: $error_msg"
|
|
log_error ""
|
|
log_error "Common issues:"
|
|
log_error " - Insufficient cloud credit (minimum \$3 required)"
|
|
log_error " - Flavor not available"
|
|
log_error " - SSH key not found"
|
|
return 1
|
|
fi
|
|
|
|
# Extract server ID
|
|
RAMNODE_SERVER_ID=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['server']['id'])")
|
|
export RAMNODE_SERVER_ID
|
|
|
|
log_info "Server created: ID=$RAMNODE_SERVER_ID"
|
|
|
|
# Wait for server to get IP address
|
|
log_info "Waiting for IP address..."
|
|
local max_attempts=30
|
|
local attempt=0
|
|
while [[ $attempt -lt $max_attempts ]]; do
|
|
sleep 2
|
|
local server_info
|
|
server_info=$(ramnode_compute_api GET "/servers/$RAMNODE_SERVER_ID")
|
|
|
|
RAMNODE_SERVER_IP=$(echo "$server_info" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
addresses = data.get('server', {}).get('addresses', {})
|
|
for net_name, addrs in addresses.items():
|
|
for addr in addrs:
|
|
if addr.get('version') == 4:
|
|
print(addr['addr'])
|
|
sys.exit(0)
|
|
" 2>/dev/null || echo "")
|
|
|
|
if [[ -n "$RAMNODE_SERVER_IP" ]]; then
|
|
export RAMNODE_SERVER_IP
|
|
log_info "IP address assigned: $RAMNODE_SERVER_IP"
|
|
return 0
|
|
fi
|
|
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
log_error "Timeout waiting for IP address"
|
|
return 1
|
|
}
|
|
|
|
# Wait for SSH connectivity
|
|
verify_server_connectivity() {
|
|
local ip="$1"
|
|
local max_attempts=${2:-30}
|
|
# SSH_OPTS is defined in shared/common.sh
|
|
# shellcheck disable=SC2154
|
|
generic_ssh_wait "root" "$ip" "$SSH_OPTS -o ConnectTimeout=5" "echo ok" "SSH connectivity" "$max_attempts" 5
|
|
}
|
|
|
|
# Run a command on the server
|
|
run_server() {
|
|
local ip="$1"
|
|
local cmd="$2"
|
|
# shellcheck disable=SC2086
|
|
ssh $SSH_OPTS "root@$ip" "$cmd"
|
|
}
|
|
|
|
# Upload a file to the server
|
|
upload_file() {
|
|
local ip="$1"
|
|
local local_path="$2"
|
|
local remote_path="$3"
|
|
# shellcheck disable=SC2086
|
|
scp $SSH_OPTS "$local_path" "root@$ip:$remote_path"
|
|
}
|
|
|
|
# Start an interactive SSH session
|
|
interactive_session() {
|
|
local ip="$1"
|
|
local cmd="$2"
|
|
# shellcheck disable=SC2086
|
|
ssh -t $SSH_OPTS "root@$ip" "$cmd"
|
|
}
|
|
|
|
# Destroy a RamNode server
|
|
destroy_server() {
|
|
local server_id="$1"
|
|
|
|
log_warn "Destroying server $server_id..."
|
|
local response
|
|
response=$(ramnode_compute_api DELETE "/servers/$server_id")
|
|
|
|
if echo "$response" | grep -q '"error"'; then
|
|
log_error "Failed to destroy server: $response"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Server $server_id destroyed"
|
|
}
|
|
|
|
# List all RamNode servers
|
|
list_servers() {
|
|
local response
|
|
response=$(ramnode_compute_api GET "/servers/detail")
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
servers = data.get('servers', [])
|
|
if not servers:
|
|
print('No servers found')
|
|
sys.exit(0)
|
|
print(f\"{'NAME':<25} {'ID':<38} {'STATUS':<12} {'IP':<16}\")
|
|
print('-' * 91)
|
|
for s in servers:
|
|
name = s['name']
|
|
sid = str(s['id'])
|
|
status = s['status']
|
|
# Extract IP
|
|
ip = 'N/A'
|
|
addresses = s.get('addresses', {})
|
|
for net_name, addrs in addresses.items():
|
|
for addr in addrs:
|
|
if addr.get('version') == 4:
|
|
ip = addr['addr']
|
|
break
|
|
if ip != 'N/A':
|
|
break
|
|
print(f'{name:<25} {sid:<38} {status:<12} {ip:<16}')
|
|
" <<< "$response"
|
|
}
|