blob: 9e11e6bb94b1c4c0f8f88eb53a4cfca8f1f38a89 [file]
#!/bin/bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
##############################################################################
# network-namespace.sh (network-namespace)
#
# Proxy script for the network-namespace CloudStack extension.
# Runs on the CloudStack management server.
#
# Invocation model:
# network-namespace.sh <command> <payload-file> <timeout-seconds>
#
# The payload JSON includes top-level extension details:
# physical-network-extension-details
# network-extension-details
#
# For standard commands, command-specific keys are nested under payload.{...}.
# For custom-action, command-specific keys are top-level (flat payload).
#
# Two runtime modes:
# 1) ensure-network-device (local, no SSH): selects/revalidates host and emits
# a single-line JSON object like:
# {"host":"192.168.1.10","namespace":"cs-net-42"}
# 2) all other commands: forwards the payload file to the selected host and
# executes network-namespace-wrapper.sh remotely.
#
# Common extension-detail keys (inside physical-network-extension-details):
# hosts, host, port, username, password, sshkey
#
# ---- SSH authentication priority ----
# 1. sshkey field in --physical-network-extension-details → PEM key
# 2. password field → sshpass(1)
# 3. No credentials → relies on SSH agent / host keys on mgmt server
#
# Exit codes:
# 0 – success
# 1 – usage / configuration error
# 2 – SSH connection / authentication error
# 3 – remote command returned non-zero
##############################################################################
set -euo pipefail
DEFAULT_SSH_PORT=22
DEFAULT_SSH_USER=root
# ---------------------------------------------------------------------------
# Resolve this entry-point's absolute path so we can derive both the KVM
# wrapper path and the log file name from the extension directory name.
#
# Layout:
# management server: /usr/share/cloudstack-management/extensions/<name>/<name>.sh
# KVM host (wrapper): /etc/cloudstack/extensions/<name>/<name>-wrapper.sh
#
# _EXT_DIR_NAME is the basename of the directory containing this script,
# which equals the extension name assigned by CloudStack (e.g.
# "extnet-isolated-gk3yys"). Both the wrapper path and the log file are
# derived from it so that renamed deployments work automatically.
#
# Callers may still override the remote path via CS_NET_SCRIPT_PATH:
# CS_NET_SCRIPT_PATH=/custom/path/wrapper.sh network-namespace.sh <cmd> ...
# ---------------------------------------------------------------------------
_SELF="$(readlink -f "$0" 2>/dev/null \
|| realpath "$0" 2>/dev/null \
|| echo "$0")"
_SCRIPT_BASENAME="$(basename "${_SELF}" .sh)"
_EXT_DIR_NAME="$(basename "$(dirname "${_SELF}")")"
# Remote wrapper path on each KVM host.
DEFAULT_SCRIPT_PATH="/etc/cloudstack/extensions/${_EXT_DIR_NAME}/${_SCRIPT_BASENAME}-wrapper.sh"
# Log file — under /var/log/cloudstack/extensions/ named after the extension.
LOG_FILE="/tmp/cloudstack-extensions/${_EXT_DIR_NAME}.log"
mkdir -p "$(dirname "${LOG_FILE}")" 2>/dev/null || true
TMPDIR_BASE=/tmp
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
log() {
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
printf '[%s] %s\n' "${ts}" "$*" >> "${LOG_FILE}" 2>/dev/null || true
}
die() {
log "ERROR: $*"
exit "${2:-1}"
}
# ---------------------------------------------------------------------------
# JSON helpers (no jq dependency)
# ---------------------------------------------------------------------------
json_get() {
# json_get <json> <key> → unquoted string value or empty
printf '%s' "$1" | grep -o "\"$2\":\"[^\"]*\"" | cut -d'"' -f4 || true
}
# ---------------------------------------------------------------------------
# Validate input and parse command
# ---------------------------------------------------------------------------
if [ $# -lt 1 ]; then
die "Usage: network-namespace.sh <command> <payload-file> <timeout-seconds>" 1
fi
COMMAND="$1"
shift || true
PAYLOAD_FILE=""
TIMEOUT_SECONDS=""
PAYLOAD_MODE="false"
if [ $# -ge 1 ] && [ -f "${1}" ]; then
PAYLOAD_FILE="$1"
TIMEOUT_SECONDS="${2:-60}"
PAYLOAD_MODE="true"
shift || true
[ $# -gt 0 ] && shift || true
fi
payload_json_get() {
# payload_json_get <file> <path> where path is dot-separated JSON path
python3 - "$1" "$2" <<'PY'
import json, sys
with open(sys.argv[1], encoding='utf-8') as fh:
data = json.load(fh)
cur = data
for part in sys.argv[2].split('.'):
if isinstance(cur, dict):
cur = cur.get(part)
else:
cur = None
if cur is None:
break
if cur is None:
print("")
elif isinstance(cur, (dict, list)):
print(json.dumps(cur, separators=(",", ":")))
else:
print(str(cur))
PY
}
# ---------------------------------------------------------------------------
# Parse CLI arguments: extract known flags, collect the rest as FORWARD_ARGS
# ---------------------------------------------------------------------------
PHYS_DETAILS="{}"
EXTENSION_DETAILS="{}"
NETWORK_ID=""
CURRENT_DETAILS="{}"
VPC_ID=""
FORWARD_ARGS=()
if [ "${PAYLOAD_MODE}" = "true" ]; then
PHYS_DETAILS=$(payload_json_get "${PAYLOAD_FILE}" "physical-network-extension-details")
EXTENSION_DETAILS=$(payload_json_get "${PAYLOAD_FILE}" "network-extension-details")
if [ "${COMMAND}" = "custom-action" ]; then
NETWORK_ID=$(payload_json_get "${PAYLOAD_FILE}" "network_id")
VPC_ID=$(payload_json_get "${PAYLOAD_FILE}" "vpc_id")
else
NETWORK_ID=$(payload_json_get "${PAYLOAD_FILE}" "payload.network_id")
VPC_ID=$(payload_json_get "${PAYLOAD_FILE}" "payload.vpc_id")
CURRENT_DETAILS=$(payload_json_get "${PAYLOAD_FILE}" "payload.current_details")
[ -z "${CURRENT_DETAILS}" ] && CURRENT_DETAILS="{}"
fi
else
while [ $# -gt 0 ]; do
case "$1" in
--physical-network-extension-details)
PHYS_DETAILS="${2:-{}}"
shift 2 ;;
--network-extension-details)
EXTENSION_DETAILS="${2:-{}}"
shift 2 ;;
--network-id)
NETWORK_ID="${2:-}"
FORWARD_ARGS+=("$1" "$2")
shift 2 ;;
--vpc-id)
VPC_ID="${2:-}"
FORWARD_ARGS+=("$1" "$2")
shift 2 ;;
--current-details)
CURRENT_DETAILS="${2:-{}}"
shift 2 ;;
*)
FORWARD_ARGS+=("$1")
shift ;;
esac
done
fi
REMOTE_SCRIPT="${CS_NET_SCRIPT_PATH:-${DEFAULT_SCRIPT_PATH}}"
REMOTE_PORT=$(json_get "${PHYS_DETAILS}" "port")
REMOTE_USER=$(json_get "${PHYS_DETAILS}" "username")
REMOTE_PASS=$(json_get "${PHYS_DETAILS}" "password")
REMOTE_SSHKEY=$(json_get "${PHYS_DETAILS}" "sshkey")
HOSTS_CSV=$(json_get "${PHYS_DETAILS}" "hosts")
SINGLE_HOST=$(json_get "${PHYS_DETAILS}" "host")
REMOTE_PORT="${REMOTE_PORT:-${DEFAULT_SSH_PORT}}"
REMOTE_USER="${REMOTE_USER:-${DEFAULT_SSH_USER}}"
# Build the candidate host list
if [ -n "${HOSTS_CSV}" ]; then
IFS=',' read -ra HOST_LIST <<< "${HOSTS_CSV}"
elif [ -n "${SINGLE_HOST}" ]; then
HOST_LIST=("${SINGLE_HOST}")
else
HOST_LIST=()
fi
# ---------------------------------------------------------------------------
# SSH helpers
# ---------------------------------------------------------------------------
KEY_TMPFILE=""
KEY_TMPDIR=""
cleanup() {
local rc=$?
if [ -n "${KEY_TMPDIR}" ] && [ -d "${KEY_TMPDIR}" ]; then
rm -rf "${KEY_TMPDIR}" 2>/dev/null || true
fi
exit ${rc}
}
trap cleanup EXIT INT TERM
setup_ssh_key() {
if [ -n "${REMOTE_SSHKEY}" ] && [ -z "${KEY_TMPFILE}" ]; then
KEY_TMPDIR=$(mktemp -d "${TMPDIR_BASE}/.cs-extnet-key-XXXXXX")
chmod 700 "${KEY_TMPDIR}"
KEY_TMPFILE="${KEY_TMPDIR}/id_extnet"
printf '%s\n' "${REMOTE_SSHKEY}" > "${KEY_TMPFILE}"
chmod 600 "${KEY_TMPFILE}"
fi
}
ssh_opts() {
local opts=(
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o LogLevel=ERROR
-o ConnectTimeout=10
-p "${REMOTE_PORT}"
)
if [ -n "${KEY_TMPFILE}" ]; then
opts+=(-i "${KEY_TMPFILE}" -o IdentitiesOnly=yes -o BatchMode=yes)
elif [ -n "${REMOTE_PASS}" ]; then
# When using password-based auth we should not force an IdentityFile of /dev/null
# because recent OpenSSH may attempt to parse it and emit libcrypto errors
# (seen as: Load key "/dev/null": error in libcrypto). Just rely on sshpass
# (SSHPASS) to provide the password if needed.
opts+=(-o IdentitiesOnly=yes)
fi
printf '%s\n' "${opts[@]}"
}
host_reachable() {
local host="$1"
setup_ssh_key
local opts
mapfile -t opts < <(ssh_opts)
if [ -n "${REMOTE_SSHKEY}" ]; then
ssh "${opts[@]}" "${REMOTE_USER}@${host}" "echo ok" >/dev/null 2>&1
elif [ -n "${REMOTE_PASS}" ]; then
command -v sshpass >/dev/null 2>&1 || return 1
SSHPASS="${REMOTE_PASS}" sshpass -e \
ssh "${opts[@]}" "${REMOTE_USER}@${host}" "echo ok" >/dev/null 2>&1
else
ssh "${opts[@]}" "${REMOTE_USER}@${host}" "echo ok" >/dev/null 2>&1
fi
}
ssh_exec() {
local host="$1"
local remote_cmd="$2"
setup_ssh_key
local opts
mapfile -t opts < <(ssh_opts)
if [ -n "${REMOTE_SSHKEY}" ]; then
ssh "${opts[@]}" "${REMOTE_USER}@${host}" "${remote_cmd}"
elif [ -n "${REMOTE_PASS}" ]; then
command -v sshpass >/dev/null 2>&1 || \
die "password set but sshpass not installed. Use sshkey instead." 2
SSHPASS="${REMOTE_PASS}" sshpass -e \
ssh "${opts[@]}" "${REMOTE_USER}@${host}" "${remote_cmd}"
else
ssh "${opts[@]}" "${REMOTE_USER}@${host}" "${remote_cmd}"
fi
}
upload_file_to_remote() {
local host="$1" local_file="$2" tag="$3"
[ -f "${local_file}" ] || die "Missing local payload file: ${local_file}" 1
local remote_tmp
remote_tmp=$(ssh_exec "${host}" "mktemp /tmp/cs-extnet-${tag}-XXXXXX") || \
die "Failed to create remote temp file for ${tag}" 2
remote_tmp=$(printf '%s' "${remote_tmp}" | tr -d '\r\n')
[ -n "${remote_tmp}" ] || die "Failed to resolve remote temp file for ${tag}" 2
cat "${local_file}" | ssh_exec "${host}" "cat > '${remote_tmp}' && chmod 600 '${remote_tmp}'" || \
die "Failed to upload payload file for ${tag}" 2
printf '%s' "${remote_tmp}"
}
# ---------------------------------------------------------------------------
# ensure-network-device
# ---------------------------------------------------------------------------
if [ "${COMMAND}" = "ensure-network-device" ]; then
[ -z "${NETWORK_ID}" ] && [ -z "${VPC_ID}" ] && die "ensure-network-device: missing --network-id or --vpc-id" 1
if [ ${#HOST_LIST[@]} -eq 0 ]; then
die "ensure-network-device: no hosts configured. Set 'hosts' in registerExtension details." 1
fi
# Namespace names must match those used by the wrapper on the KVM host.
# VPC networks share one namespace per VPC (cs-vpc-<vpcId>);
# standalone networks (Isolated and Shared) each get their own namespace (cs-net-<networkId>).
if [ -n "${VPC_ID}" ]; then
NAMESPACE="cs-vpc-${VPC_ID}"
else
NAMESPACE="cs-net-${NETWORK_ID}"
fi
# ---- Step 1: honour the previously selected host (sticky assignment) ----
# This preserves the host–namespace binding across API calls once a network
# has been implemented on a particular KVM host.
CURRENT_HOST=$(json_get "${CURRENT_DETAILS}" "host")
[ -z "${CURRENT_HOST}" ] && CURRENT_HOST=$(json_get "${EXTENSION_DETAILS}" "host")
if [ -n "${CURRENT_HOST}" ]; then
for h in "${HOST_LIST[@]}"; do
h="${h// /}"
if [ "${h}" = "${CURRENT_HOST}" ]; then
if host_reachable "${CURRENT_HOST}"; then
log "ensure-network-device: ${NETWORK_ID:+network=${NETWORK_ID} }${VPC_ID:+vpc=${VPC_ID} }keeping current host=${CURRENT_HOST}"
if [ -n "${VPC_ID}" ]; then
printf '{"host":"%s","namespace":"%s","vpc_id":"%s"}\n' \
"${CURRENT_HOST}" "${NAMESPACE}" "${VPC_ID}"
else
printf '{"host":"%s","namespace":"%s"}\n' \
"${CURRENT_HOST}" "${NAMESPACE}"
fi
exit 0
else
log "ensure-network-device: current host ${CURRENT_HOST} not reachable — failover"
fi
break
fi
done
fi
# ---- Step 2: stable hash-based host selection for new / failed-over networks ----
#
# For VPC networks ALL tiers must land on the same KVM host (they share one
# namespace). Using VPC_ID as the hash key guarantees every tier in a VPC
# hashes to the same preferred index even when its own details are not yet
# stored. For isolated networks the NETWORK_ID is used.
#
# Algorithm: CRC32 of the routing key (via cksum) modulo the host count
# gives a stable preferred index. We probe hosts starting from that index,
# wrapping around, until a reachable one is found. This distributes
# different networks evenly across KVM hosts while remaining deterministic.
_ROUTE_KEY="${VPC_ID:-${NETWORK_ID}}"
_HOST_COUNT="${#HOST_LIST[@]}"
_PREFERRED_IDX=$(printf '%s' "${_ROUTE_KEY}" | cksum | awk -v n="${_HOST_COUNT}" '{print ($1 % n)}')
_SELECTED_HOST=""
_PROBE=0
while [ "${_PROBE}" -lt "${_HOST_COUNT}" ]; do
_IDX=$(( (_PREFERRED_IDX + _PROBE) % _HOST_COUNT ))
_H="${HOST_LIST[$_IDX]// /}"
if host_reachable "${_H}"; then
_SELECTED_HOST="${_H}"
log "ensure-network-device: ${NETWORK_ID:+network=${NETWORK_ID} }${VPC_ID:+vpc=${VPC_ID} }hash-selected host=${_SELECTED_HOST} (key=${_ROUTE_KEY}, idx=${_IDX})"
break
fi
log "ensure-network-device: host ${_H} not reachable, trying next"
_PROBE=$(( _PROBE + 1 ))
done
[ -z "${_SELECTED_HOST}" ] && \
die "ensure-network-device: no reachable host found in list: ${HOSTS_CSV:-${SINGLE_HOST}}" 1
if [ -n "${VPC_ID}" ]; then
printf '{"host":"%s","namespace":"%s","vpc_id":"%s"}\n' \
"${_SELECTED_HOST}" "${NAMESPACE}" "${VPC_ID}"
else
printf '{"host":"%s","namespace":"%s"}\n' "${_SELECTED_HOST}" "${NAMESPACE}"
fi
exit 0
fi
# ---------------------------------------------------------------------------
# All other commands: forward via SSH to the selected network device
# ---------------------------------------------------------------------------
REMOTE_HOST=$(json_get "${EXTENSION_DETAILS}" "host")
if [ -z "${REMOTE_HOST}" ]; then
REMOTE_HOST="${SINGLE_HOST:-}"
[ -z "${REMOTE_HOST}" ] && [ ${#HOST_LIST[@]} -gt 0 ] && REMOTE_HOST="${HOST_LIST[0]// /}"
fi
[ -z "${REMOTE_HOST}" ] && die "No target host available. Run ensure-network-device first." 1
# Build and execute remote command
REMOTE_PAYLOAD_FILES=()
if [ "${PAYLOAD_MODE}" = "true" ]; then
REMOTE_PAYLOAD_FILE=$(upload_file_to_remote "${REMOTE_HOST}" "${PAYLOAD_FILE}" "payload")
REMOTE_PAYLOAD_FILES+=("${REMOTE_PAYLOAD_FILE}")
REMOTE_CMD="'${REMOTE_SCRIPT}' '${COMMAND}' '${REMOTE_PAYLOAD_FILE//"'"/"'\\''"}' '${TIMEOUT_SECONDS}'"
else
remote_args=()
for arg in "${FORWARD_ARGS[@]}"; do
remote_args+=("'${arg//"'"/"'\\''"}'" )
done
PHYS_ESCAPED="${PHYS_DETAILS//\'/\'\\\'\'}"
EXT_ESCAPED="${EXTENSION_DETAILS//\'/\'\\\'\'}"
REMOTE_CMD="'${REMOTE_SCRIPT}' '${COMMAND}' ${remote_args[*]} --physical-network-extension-details '${PHYS_ESCAPED}' --network-extension-details '${EXT_ESCAPED}'"
fi
log "Remote: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT} cmd=${COMMAND}"
RC=0
ssh_exec "${REMOTE_HOST}" "${REMOTE_CMD}" || RC=$?
if [ ${#REMOTE_PAYLOAD_FILES[@]} -gt 0 ]; then
for _rf in "${REMOTE_PAYLOAD_FILES[@]}"; do
ssh_exec "${REMOTE_HOST}" "rm -f '${_rf}'" >/dev/null 2>&1 || true
done
fi
if [ ${RC} -ne 0 ]; then
if [ ${RC} -eq 255 ]; then
log "SSH connection failed (rc=255): host=${REMOTE_HOST}:${REMOTE_PORT} user=${REMOTE_USER}"
exit 2
fi
log "Remote script returned rc=${RC}"
exit 3
fi
log "Command '${COMMAND}' completed successfully on ${REMOTE_HOST}"
exit 0