diff --git a/.github/scripts/wg-env.sh b/.github/scripts/wg-env.sh new file mode 100644 index 000000000..58d332135 --- /dev/null +++ b/.github/scripts/wg-env.sh @@ -0,0 +1,1121 @@ +#!/usr/bin/env bash +# +# wg-env.sh - WireGuard environment onboard/offboard for self-service QA/dev. +# +# Subcommands: +# onboard - Automates the manual jumpserver onboarding process: +# 1. SSH into the WireGuard VM. +# 2. cd into the WireGuard env dir (default /home/ubuntu/wireguard_env_2026). +# 3. Pick the lowest free peers in assigned.txt (peer1..peerN, filling gaps). +# Uses existing peerN/peerN.conf on the jumpserver — no key generation. +# Records assignments and rewrites assigned.txt in peer-number order. +# Supports two assigned.txt layouts: +# peerN: username (legacy colon format on /home/ubuntu) +# peerN env(SECRET_NAME) (MOSIP per-secret format) +# 4. For each peer's client conf (config/peerN/peerN.conf): +# - remove the `DNS = ...` line +# - set `AllowedIPs = 172.31.0.0/16` +# 5. Publish the 3 transformed confs as GitHub *environment* secrets +# (TF_WG_CONFIG, CLUSTER_WIREGUARD_WG0, CLUSTER_WIREGUARD_WG1) on the +# environment whose name == the branch/env name. +# +# offboard - Free assigned.txt lines for the environment, delete GitHub env secrets, +# update repo tracker. Does not modify WireGuard keys or server config. +# +# Three distinct peers are used on onboard because the Helmsman wg0/wg1 matrix +# jobs run concurrently and Terraform uses its own peer too. +# +# Repo tracker: wg-peer-allocation.tsv (header + rows; committed by wg-onboard.yml). +# If two onboard runs update the tracker concurrently, the workflow rebase may fail — +# resolve the TSV conflict manually and re-run offboard/onboard for the affected env. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "${ALLOCATION_FILE:-}" ]]; then + for _tracker in \ + "$SCRIPT_DIR/wg-peer-allocation.tsv" \ + "$SCRIPT_DIR/../scripts/wg-peer-allocation.tsv"; do + if [[ -f "$_tracker" ]]; then + ALLOCATION_FILE="$_tracker" + break + fi + done + ALLOCATION_FILE="${ALLOCATION_FILE:-$SCRIPT_DIR/wg-peer-allocation.tsv}" +fi + +# Defaults (override via flags or env vars) +SSH_USER="${SSH_USER:-ubuntu}" +SSH_KEY="${SSH_KEY:-}" +JUMPSERVER_HOST="${JUMPSERVER_HOST:-}" +ENV_NAME="${ENV_NAME:-}" +REPO="${REPO:-}" +WG_DIR="${WG_DIR:-/home/ubuntu/wireguard_env_2026}" +ALLOWED_IPS="${ALLOWED_IPS:-172.31.0.0/16}" +TICKET="${TICKET:-}" +TF_PEER="" +WG0_PEER="" +WG1_PEER="" +MAX_PEERS="" +DRY_RUN="false" +DELETE_ENVIRONMENT="false" +ACTION="" + +# Secret name -> peer variable mapping is fixed in this order. +SECRET_NAMES=(TF_WG_CONFIG CLUSTER_WIREGUARD_WG0 CLUSTER_WIREGUARD_WG1) + +usage() { + cat <<'EOF' +Usage: wg-env.sh --env --host --ssh-key [options] + +Subcommands: + onboard Allocate peers and publish GitHub environment secrets + offboard Release peers and delete GitHub environment secrets + +Required: + --env Environment / branch name (also the label in assigned.txt + and the GitHub environment to target) + --host Jumpserver public IP or DNS (SSH reachable) + --ssh-key Path to the private key for ubuntu@jumpserver + +Optional (both): + --repo GitHub repo (default: inferred from `gh repo view`) + --wg-dir WireGuard env dir on the VM (default: /home/ubuntu/wireguard_env_2026) + --dry-run Print actions without writing anything + -h, --help Show this help + +Onboard only: + --ticket Ticket id to record in assigned.txt, e.g. DSD-10264 + --allowed-ips AllowedIPs to set in each conf (default: 172.31.0.0/16) + --tf-peer Force the TF_WG_CONFIG peer (default: next free) + --wg0-peer Force the CLUSTER_WIREGUARD_WG0 peer (default: next free) + --wg1-peer Force the CLUSTER_WIREGUARD_WG1 peer (default: next free) + --max-peers Peer pool size peer1..peerN (default: max(100, highest seen)) + +Offboard only: + --delete-environment Also delete the GitHub environment object (default: secrets only) + --keep-environment Delete VPN secrets only; keep the GitHub environment object (default) + +Requires: gh (authenticated with a token that can write environment secrets), ssh. +EOF +} + +log() { echo "[wg-env] $*" >&2; } +err() { echo "[wg-env][ERROR] $*" >&2; } +die() { err "$*"; exit 1; } + +require_arg() { + local flag="$1" + [[ $# -ge 2 && -n "${2:-}" && "$2" != --* ]] || die "$flag requires a value" +} + +urlencode() { + local input="$1" output="" i c + local LC_ALL=C + for ((i = 0; i < ${#input}; i++)); do + c="${input:i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) output+="$c" ;; + *) printf -v output '%s%%%02X' "$output" "'$c" ;; + esac + done + printf '%s' "$output" +} + +remote_quote() { + local s=${1//\'/\'\\\'\'} + printf "'%s'" "$s" +} + +if [[ $# -eq 0 ]]; then + usage + exit 1 +fi +case "$1" in + onboard|offboard) ACTION="$1"; shift ;; + -h|--help) usage; exit 0 ;; + --*) ACTION="onboard" ;; + *) die "First argument must be 'onboard' or 'offboard' (use --help)" ;; +esac + +while [[ $# -gt 0 ]]; do + case "$1" in + --env) require_arg --env "${2-}"; ENV_NAME="$2"; shift 2 ;; + --host) require_arg --host "${2-}"; JUMPSERVER_HOST="$2"; shift 2 ;; + --ssh-key) require_arg --ssh-key "${2-}"; SSH_KEY="$2"; shift 2 ;; + --repo) require_arg --repo "${2-}"; REPO="$2"; shift 2 ;; + --ticket) require_arg --ticket "${2-}"; TICKET="$2"; shift 2 ;; + --wg-dir) require_arg --wg-dir "${2-}"; WG_DIR="$2"; shift 2 ;; + --allowed-ips) require_arg --allowed-ips "${2-}"; ALLOWED_IPS="$2"; shift 2 ;; + --tf-peer) require_arg --tf-peer "${2-}"; TF_PEER="$2"; shift 2 ;; + --wg0-peer) require_arg --wg0-peer "${2-}"; WG0_PEER="$2"; shift 2 ;; + --wg1-peer) require_arg --wg1-peer "${2-}"; WG1_PEER="$2"; shift 2 ;; + --max-peers) require_arg --max-peers "${2-}"; MAX_PEERS="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --delete-environment) DELETE_ENVIRONMENT="true"; shift ;; + --keep-environment) DELETE_ENVIRONMENT="false"; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Unknown argument: $1 (use --help)" ;; + esac +done + +[[ -n "$ENV_NAME" ]] || die "--env is required" +[[ -n "$JUMPSERVER_HOST" ]] || die "--host is required" +[[ -n "$SSH_KEY" ]] || die "--ssh-key is required" +[[ -f "$SSH_KEY" ]] || die "ssh key not found: $SSH_KEY" +command -v gh >/dev/null 2>&1 || die "gh CLI is required" +command -v ssh >/dev/null 2>&1 || die "ssh is required" + +if [[ -z "$REPO" ]]; then + REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)" + [[ -n "$REPO" ]] || die "Could not infer repo; pass --repo " +fi + +# gh secret/variable --env does not URL-encode the name; # ( ) / etc. break API paths. +ENV_NAME_ENC="$(urlencode "$ENV_NAME")" + +CONFIG_DIR="$WG_DIR/config" +ASSIGNED_FILE="$WG_DIR/assigned.txt" +ASSIGN_LOCK_FILE="/tmp/wg-env-$(printf '%s' "$ASSIGNED_FILE" | cksum | awk '{print $1}').lock" +LABEL="$ENV_NAME" +[[ -n "$TICKET" ]] && LABEL="${ENV_NAME}(${TICKET})" + +log "Action: $ACTION" +log "Target repo: $REPO" +log "Target environment: $ENV_NAME" +log "WireGuard dir: $WG_DIR" +if [[ "$ACTION" == "onboard" ]]; then + [[ "$ALLOWED_IPS" =~ ^[0-9]+(\.[0-9]+){3}/[0-9]+$ ]] \ + || die "--allowed-ips must be an IPv4 CIDR (e.g. 172.31.0.0/16), got: $ALLOWED_IPS" + log "Onboard label: $LABEL | AllowedIPs: $ALLOWED_IPS" +fi + +SSH_KNOWN_HOSTS_IS_TEMP="false" +TRACKER_TMP="" +wg_env_exit_cleanup() { + [[ -n "$TRACKER_TMP" && -f "$TRACKER_TMP" ]] && rm -f "$TRACKER_TMP" + [[ "$SSH_KNOWN_HOSTS_IS_TEMP" == "true" ]] && rm -f "$SSH_KNOWN_HOSTS" +} +trap wg_env_exit_cleanup EXIT + +if [[ -n "${SSH_KNOWN_HOSTS:-}" ]]; then + [[ -f "$SSH_KNOWN_HOSTS" ]] || die "SSH_KNOWN_HOSTS file not found: $SSH_KNOWN_HOSTS" +else + SSH_KNOWN_HOSTS="$(mktemp /tmp/wg_env_known_hosts.XXXXXX)" + SSH_KNOWN_HOSTS_IS_TEMP="true" +fi + +ssh_cmd() { + ssh -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=accept-new \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ + -o ConnectTimeout=15 \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + "${SSH_USER}@${JUMPSERVER_HOST}" "$@" +} + +# Run a remote bash script from stdin with positional args ($1, $2, ...). +# OpenSSH passes the remote command through the login shell (bash -c), so args +# with metacharacters (spaces, parentheses, #, etc.) must be shell-quoted or +# the remote -c parse fails before bash -s runs. remote_quote() is for inline +# one-liners (ls, test, cat); use printf %q here for positional args. +ssh_bash_stdin() { + local quoted=() arg + for arg in "$@"; do + quoted+=("$(printf '%q' "$arg")") + done + ssh_cmd bash -s -- "${quoted[@]}" +} + +run_offboard() { + resolve_peers_from_assigned() { + local content="$1" + local line peer tf wg0 wg1 + local -A seen=() + local -a peers=() + + content="${content//$'\r'/}" + [[ -n "${content//[[:space:]]/}" ]] || return 0 + + if grep -qE '^peer[0-9]+[[:space:]]*:' <<<"$content"; then + while IFS= read -r line; do + [[ "$line" =~ ^peer([0-9]+)[[:space:]]*:[[:space:]]*(.*)$ ]] || continue + peer="peer${BASH_REMATCH[1]}" + [[ "${BASH_REMATCH[2]}" == "$ENV_NAME" ]] || continue + [[ -n "${seen[$peer]:-}" ]] && continue + seen["$peer"]=1 + peers+=("$peer") + done <<<"$content" + else + tf="$(awk -v env="$ENV_NAME" -v secret="TF_WG_CONFIG" ' + $1 ~ /^peer[0-9]+:?$/ { + peer=$1; sub(/:$/, "", peer) + desc=$0; sub(/^[[:space:]]*peer[0-9]+[[:space:]]*:?[[:space:]]*/, "", desc) + suffix="(" secret ")" + if (length(desc) < length(suffix)) next + if (substr(desc, length(desc) - length(suffix) + 1) != suffix) next + label=substr(desc, 1, length(desc) - length(suffix)) + if (label == env || index(label, env "(") == 1) { print peer; exit } + }' <<<"$content")" + wg0="$(awk -v env="$ENV_NAME" -v secret="CLUSTER_WIREGUARD_WG0" ' + $1 ~ /^peer[0-9]+:?$/ { + peer=$1; sub(/:$/, "", peer) + desc=$0; sub(/^[[:space:]]*peer[0-9]+[[:space:]]*:?[[:space:]]*/, "", desc) + suffix="(" secret ")" + if (length(desc) < length(suffix)) next + if (substr(desc, length(desc) - length(suffix) + 1) != suffix) next + label=substr(desc, 1, length(desc) - length(suffix)) + if (label == env || index(label, env "(") == 1) { print peer; exit } + }' <<<"$content")" + wg1="$(awk -v env="$ENV_NAME" -v secret="CLUSTER_WIREGUARD_WG1" ' + $1 ~ /^peer[0-9]+:?$/ { + peer=$1; sub(/:$/, "", peer) + desc=$0; sub(/^[[:space:]]*peer[0-9]+[[:space:]]*:?[[:space:]]*/, "", desc) + suffix="(" secret ")" + if (length(desc) < length(suffix)) next + if (substr(desc, length(desc) - length(suffix) + 1) != suffix) next + label=substr(desc, 1, length(desc) - length(suffix)) + if (label == env || index(label, env "(") == 1) { print peer; exit } + }' <<<"$content")" + for peer in "$tf" "$wg0" "$wg1"; do + [[ -n "$peer" ]] || continue + [[ -n "${seen[$peer]:-}" ]] && continue + seen["$peer"]=1 + peers+=("$peer") + done + fi + + printf '%s\n' "${peers[@]}" + } + + delete_github_secrets() { + local name + for name in "${SECRET_NAMES[@]}"; do + if gh secret delete "$name" --env "$ENV_NAME_ENC" --repo "$REPO" 2>/dev/null; then + log "Deleted secret $name from environment $ENV_NAME" + else + log "Secret $name not present (or could not delete) in environment $ENV_NAME" + fi + done + if [[ "$DELETE_ENVIRONMENT" == "true" ]]; then + if gh api --method DELETE -H "Accept: application/vnd.github+json" \ + "repos/${REPO}/environments/${ENV_NAME_ENC}" >/dev/null 2>&1; then + log "Deleted GitHub environment '$ENV_NAME'" + else + log "GitHub environment '$ENV_NAME' not present (or could not delete)" + fi + fi + } + + atomic_free_assignments() { + [[ -n "${ASSIGNED_CONTENT//[[:space:]]/}" ]] || return 0 + + ssh_bash_stdin \ + "$ASSIGNED_FILE" \ + "$ASSIGN_LOCK_FILE" \ + "$ENV_NAME" \ + <<'REMOTE_FREE_ASSIGNMENTS' +set -euo pipefail + +ASSIGNED_FILE="$1" +LOCK_FILE="$2" +ENV_NAME="$3" + +[[ -f "$ASSIGNED_FILE" ]] || exit 0 + +exec 9>"$LOCK_FILE" || { echo "ERROR: cannot create allocation lock ($LOCK_FILE)" >&2; exit 1; } +if ! flock -w 120 9; then + echo "ERROR: timed out waiting for allocation lock ($LOCK_FILE)" >&2 + exit 1 +fi + +[[ -w "$ASSIGNED_FILE" ]] || { echo "ERROR: $ASSIGNED_FILE is not writable" >&2; exit 1; } + +atomic_replace_file() { + local src="$1" dest="$2" + if ! mv -f "$src" "$dest"; then + rm -f "$src" + echo "ERROR: cannot update $dest (check ownership/permissions)" >&2 + return 1 + fi +} + +normalize_peer_token() { + local token="${1//$'\r'/}" + token="${token%%:*}" + printf '%s' "$token" +} + +peer_num_from_token() { + local token="$1" + [[ "$token" =~ ^peer([0-9]+)$ ]] || { echo "999999"; return 0; } + echo "${BASH_REMATCH[1]}" +} + +sort_assigned_file() { + local file="$1" dir tmp line peer n + [[ -f "$file" ]] || return 0 + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.assigned.XXXXXX")" || exit 1 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "${line//[[:space:]]/}" ]] && continue + peer="$(normalize_peer_token "$(awk '{print $1}' <<<"$line")")" + n="$(peer_num_from_token "$peer")" + printf '%05d\t%s\n' "$n" "$line" + done < "$file" | sort -n | cut -f2- > "$tmp" + atomic_replace_file "$tmp" "$file" +} + +line_matches_env() { + local line="${1//$'\r'/}" peer rest + [[ -z "${line//[[:space:]]/}" ]] && return 1 + peer="$(awk '{print $1}' <<<"$line")" + peer="${peer%%:*}" + [[ "$peer" =~ ^peer[0-9]+$ ]] || return 1 + if [[ "$line" =~ ^peer[0-9]+[[:space:]]*:[[:space:]]*(.*)$ ]]; then + rest="${BASH_REMATCH[1]}" + rest="$(awk '{$1=$1; print}' <<<"${rest}")" + [[ "$rest" == "$ENV_NAME" ]] + return + fi + rest="$(awk '{$1=""; sub(/^ +/,""); print}' <<<"$line")" + case "$rest" in + "$ENV_NAME"|"$ENV_NAME"\(*) return 0 ;; + *) return 1 ;; + esac +} + +dir="$(dirname "$ASSIGNED_FILE")" +tmp="$(mktemp "$dir/.assigned.XXXXXX")" || exit 1 +removed=0 +while IFS= read -r line || [[ -n "$line" ]]; do + if line_matches_env "$line"; then + removed=$((removed + 1)) + continue + fi + printf '%s\n' "$line" +done < "$ASSIGNED_FILE" > "$tmp" + +if (( removed == 0 )); then + rm -f "$tmp" + echo "WARN: no assigned.txt lines matched $ENV_NAME" >&2 + exit 0 +fi + +atomic_replace_file "$tmp" "$ASSIGNED_FILE" +sort_assigned_file "$ASSIGNED_FILE" +echo "Removed $removed assigned.txt line(s) for $ENV_NAME" +REMOTE_FREE_ASSIGNMENTS + } + + update_repo_tracker_offboard() { + [[ -f "$ALLOCATION_FILE" ]] || return 0 + exec 8>"${ALLOCATION_FILE}.lock" + if ! flock -w 30 8; then + die "Timed out waiting to update repo tracker lock ${ALLOCATION_FILE}.lock" + fi + TRACKER_TMP="$(mktemp "$(dirname "$ALLOCATION_FILE")/.wg-peer-allocation.XXXXXX")" + awk -F '\t' -v env="$ENV_NAME" 'NR == 1 || $1 != env' "$ALLOCATION_FILE" > "$TRACKER_TMP" + chmod --reference="$ALLOCATION_FILE" "$TRACKER_TMP" 2>/dev/null || true + mv "$TRACKER_TMP" "$ALLOCATION_FILE" + TRACKER_TMP="" + } + + log "Reading assigned.txt from ${JUMPSERVER_HOST} ..." + read_status=0 + ASSIGNED_CONTENT="$( + ssh_cmd "if [[ -f $(remote_quote "$ASSIGNED_FILE") ]]; then cat $(remote_quote "$ASSIGNED_FILE"); else exit 42; fi" 2>/dev/null + )" || read_status=$? + case "$read_status" in + 0) ;; + 42) ASSIGNED_CONTENT="" ;; + *) die "Failed reading assigned.txt from ${JUMPSERVER_HOST}" ;; + esac + mapfile -t PEERS_TO_FREE < <(resolve_peers_from_assigned "$ASSIGNED_CONTENT") + + if ((${#PEERS_TO_FREE[@]})); then + log "Peers to free in assigned.txt for $ENV_NAME: ${PEERS_TO_FREE[*]}" + else + log "No assigned.txt entries found for $ENV_NAME (will still remove GitHub secrets if present)" + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log "DRY RUN - would delete GitHub secrets: ${SECRET_NAMES[*]} (env: $ENV_NAME)" + [[ "$DELETE_ENVIRONMENT" == "true" ]] \ + && log "DRY RUN - would delete GitHub environment '$ENV_NAME'" \ + || log "DRY RUN - would keep GitHub environment object" + if ((${#PEERS_TO_FREE[@]})); then + log "DRY RUN - would remove assigned.txt lines for peers: ${PEERS_TO_FREE[*]}" + fi + [[ -f "$ALLOCATION_FILE" ]] && grep -q "^${ENV_NAME}"$'\t' "$ALLOCATION_FILE" 2>/dev/null \ + && log "DRY RUN - would remove row for $ENV_NAME from $ALLOCATION_FILE" \ + || log "DRY RUN - no tracker row for $ENV_NAME in $ALLOCATION_FILE" + exit 0 + fi + + delete_github_secrets + atomic_free_assignments || die "Failed freeing peers in assigned.txt on jumpserver" + update_repo_tracker_offboard + + log "Done. Environment '$ENV_NAME' offboarded; peers freed in assigned.txt: ${PEERS_TO_FREE[*]:-(none found)}" + log "Repo tracker: $ALLOCATION_FILE (commit it if changed)." +} + +if [[ "$ACTION" == "offboard" ]]; then + run_offboard + exit 0 +fi + +# ---- Preflight: jumpserver connectivity + peer pool sizing ------------------- +log "Checking jumpserver connectivity and peer inventory on ${JUMPSERVER_HOST} ..." +PEER_LISTING="$(ssh_cmd "ls -1 $(remote_quote "$CONFIG_DIR")" 2>/dev/null || true)" +[[ -n "$PEER_LISTING" ]] || die "Could not list $CONFIG_DIR on jumpserver (check --wg-dir / connectivity)" + +mapfile -t EXISTING_PEERS < <(printf '%s\n' "$PEER_LISTING" | grep -E '^peer[0-9]+$' | sort -t r -k2 -n) + +peer_number() { + local peer="$1" + [[ "$peer" =~ ^peer([0-9]+)$ ]] || return 1 + echo "${BASH_REMATCH[1]}" +} + +highest_config_peer() { + local max=0 n peer + for peer in "${EXISTING_PEERS[@]}"; do + n="$(peer_number "$peer" || true)" + [[ -n "$n" && "$n" -gt "$max" ]] && max="$n" + done + echo "$max" +} + +if [[ -z "$MAX_PEERS" ]]; then + max="$(highest_config_peer)" + MAX_PEERS=$(( max > 100 ? max : 100 )) +else + [[ "$MAX_PEERS" =~ ^[0-9]+$ && "$MAX_PEERS" -gt 0 ]] || die "--max-peers must be a positive integer" + detected_max="$(highest_config_peer)" + [[ "$detected_max" -eq 0 || "$MAX_PEERS" -ge "$detected_max" ]] \ + || die "--max-peers ($MAX_PEERS) is less than highest peer on jumpserver (peer${detected_max})" +fi +log "Peer pool: peer1..peer${MAX_PEERS} (gap-fill order, use existing peer confs only)" + +[[ ${#EXISTING_PEERS[@]} -gt 0 ]] || die "No peer directories under $CONFIG_DIR (create peers on jumpserver first)" + +peer_config_exists() { + local peer="$1" + local conf="$CONFIG_DIR/$peer/$peer.conf" + ssh_cmd "test -f $(remote_quote "$conf")" 2>/dev/null +} + +# ---- Flock-guarded peer allocation/record on the jumpserver ---------------- +# Selection and assigned.txt recording happen under one lock to prevent races. +atomic_allocate_peers() { + # SSH omits empty argv entries; placeholders keep arg positions stable for bash -s. + local force_tf="${TF_PEER:-__none__}" + local force_wg0="${WG0_PEER:-__none__}" + local force_wg1="${WG1_PEER:-__none__}" + ssh_bash_stdin \ + "$ASSIGNED_FILE" \ + "$ASSIGN_LOCK_FILE" \ + "$CONFIG_DIR" \ + "$ENV_NAME" \ + "$LABEL" \ + "$MAX_PEERS" \ + "$force_tf" \ + "$force_wg0" \ + "$force_wg1" \ + "$DRY_RUN" \ + <<'REMOTE_ATOMIC_ALLOCATE' +set -euo pipefail + +ASSIGNED_FILE="$1" +LOCK_FILE="$2" +CONFIG_DIR="$3" +ENV_NAME="$4" +LABEL="$5" +MAX_PEERS="$6" +FORCE_TF="${7:-}" +FORCE_WG0="${8:-}" +FORCE_WG1="${9:-}" +[[ "$FORCE_TF" == "__none__" ]] && FORCE_TF="" +[[ "$FORCE_WG0" == "__none__" ]] && FORCE_WG0="" +[[ "$FORCE_WG1" == "__none__" ]] && FORCE_WG1="" +DRY_RUN="${10:-false}" + +PARSED_PEER="" +PARSED_LABEL="" + +normalize_peer_token() { + local token="${1//$'\r'/}" + token="${token%%:*}" + printf '%s' "$token" +} + +parse_assigned_line() { + local line="${1//$'\r'/}" + local peer rest + PARSED_PEER="" + PARSED_LABEL="" + [[ -z "${line//[[:space:]]/}" ]] && return 1 + peer="$(normalize_peer_token "$(awk '{print $1}' <<<"$line")")" + [[ "$peer" =~ ^peer[0-9]+$ ]] || return 1 + if [[ "$line" =~ ^peer[0-9]+[[:space:]]*:[[:space:]]*(.*)$ ]]; then + rest="${BASH_REMATCH[1]}" + else + rest="$(awk '{$1=""; sub(/^ +/,""); print}' <<<"$line")" + fi + rest="${rest//$'\r'/}" + rest="$(awk '{$1=$1; print}' <<<"$rest")" + PARSED_PEER="$peer" + PARSED_LABEL="$rest" + return 0 +} + +peer_num_from_token() { + local token="$1" + [[ "$token" =~ ^peer([0-9]+)$ ]] || { echo "999999"; return 0; } + echo "${BASH_REMATCH[1]}" +} + +peer_label_is_free() { + local label="${1//[[:space:]]/}" + [[ -z "$label" ]] && return 0 + case "${label,,}" in + available|free|unused|unassigned|none|na|n/a|-) return 0 ;; + esac + return 1 +} + +peer_label_is_taken() { + local label="$1" + ! peer_label_is_free "$label" +} + +sort_assigned_file() { + local file="$1" dir tmp line peer n + [[ -f "$file" ]] || return 0 + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.assigned.XXXXXX")" || { + echo "ERROR: cannot create temp file in $dir" >&2 + return 1 + } + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "${line//[[:space:]]/}" ]] && continue + peer="$(normalize_peer_token "$(awk '{print $1}' <<<"$line")")" + n="$(peer_num_from_token "$peer")" + printf '%05d\t%s\n' "$n" "$line" + done < "$file" | sort -n | cut -f2- > "$tmp" + atomic_replace_file "$tmp" "$file" +} + +assigned_file_writable() { + local file="$1" dir + dir="$(dirname "$file")" + if [[ -e "$file" ]]; then + if [[ ! -w "$file" ]]; then + echo "ERROR: $file is not writable by $(whoami) ($(stat -c '%U:%G %a' "$file" 2>/dev/null || echo 'stat failed'))" >&2 + echo "Fix on jumpserver: sudo chown ubuntu:ubuntu '$file' && sudo chmod u+w '$file'" >&2 + return 1 + fi + elif [[ ! -w "$dir" ]]; then + echo "ERROR: cannot create $file (directory $dir not writable by $(whoami))" >&2 + return 1 + fi +} + +atomic_replace_file() { + local src="$1" dest="$2" + if ! mv -f "$src" "$dest"; then + rm -f "$src" + echo "ERROR: cannot update $dest (check ownership/permissions)" >&2 + return 1 + fi +} + +record_assignment() { + local peer="$1" assignment="$2" format="$3" file="$4" + local safe_assignment="${assignment//\\/\\\\}" + safe_assignment="${safe_assignment//&/\\&}" + local dir tmp + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.assigned.XXXXXX")" || { + echo "ERROR: cannot create temp file in $dir" >&2 + return 1 + } + if [[ "$format" == "colon" ]]; then + awk -v peer="$peer" -v assignment="$assignment" ' + BEGIN { + replaced=0 + new_line=peer ": " assignment + } + $1 ~ ("^" peer ":$") || $1 ~ ("^" peer "$") { + if (!replaced) { + print new_line + replaced=1 + } + next + } + { print } + END { + if (!replaced) { + print new_line + } + } + ' "$file" > "$tmp" + else + awk -v peer="$peer" -v assignment="$assignment" ' + BEGIN { + replaced=0 + new_line=peer " " assignment + } + $1 ~ ("^" peer ":?$") { + if (!replaced) { + print new_line + replaced=1 + } + next + } + { print } + END { + if (!replaced) { + print new_line + } + } + ' "$file" > "$tmp" + fi + atomic_replace_file "$tmp" "$file" +} + +require_peer_conf() { + local peer_num="$1" + local PEER_ID="peer${peer_num}" + if [[ -f "$CONFIG_DIR/$PEER_ID/$PEER_ID.conf" ]]; then + return 0 + fi + echo "ERROR: $PEER_ID.conf not found under $CONFIG_DIR/$PEER_ID (use existing jumpserver peer configs only)" >&2 + return 1 +} + +mosip_peer_for_secret() { + local secret="$1" content="$2" + awk -v env="$ENV_NAME" -v secret="$secret" ' + $1 ~ /^peer[0-9]+:?$/ { + peer=$1; sub(/:$/, "", peer) + desc=$0; sub(/^[[:space:]]*peer[0-9]+[[:space:]]*:?[[:space:]]*/, "", desc) + suffix="(" secret ")" + if (length(desc) < length(suffix)) next + if (substr(desc, length(desc) - length(suffix) + 1) != suffix) next + label=substr(desc, 1, length(desc) - length(suffix)) + if (label == env || index(label, env "(") == 1) { print peer; exit } + }' <<<"$content" +} + +mark_env_peer() { + local peer="$1" + [[ -n "$peer" ]] || return 0 + ENV_PEERS["$peer"]=1 + CHOSEN["$peer"]=1 +} + +run_allocation() { + local assigned_content line n peer + local -A TAKEN=() CHOSEN=() ENV_PEERS=() + local tf="" wg0="" wg1="" reused="false" format="mosip" + local record_tf="false" record_wg0="false" record_wg1="false" + + assigned_content="$(cat "$ASSIGNED_FILE" 2>/dev/null || true)" + assigned_content="${assigned_content//$'\r'/}" + + if grep -qE '^peer[0-9]+[[:space:]]*:' <<<"$assigned_content"; then + format="colon" + fi + + while IFS= read -r line; do + parse_assigned_line "$line" || continue + peer_label_is_taken "$PARSED_LABEL" && TAKEN["$PARSED_PEER"]=1 + done <<<"$assigned_content" + + if [[ "$format" == "colon" ]]; then + mapfile -t colon_reused < <(while IFS= read -r line; do + parse_assigned_line "$line" || continue + [[ "$PARSED_LABEL" == "$ENV_NAME" ]] && echo "$PARSED_PEER" + done <<<"$assigned_content" | sort -t r -k2 -n) + tf="${colon_reused[0]:-}" + wg0="${colon_reused[1]:-}" + wg1="${colon_reused[2]:-}" + mark_env_peer "$tf" + mark_env_peer "$wg0" + mark_env_peer "$wg1" + [[ -n "$tf" && -n "$wg0" && -n "$wg1" ]] && reused="true" + else + tf="$(mosip_peer_for_secret TF_WG_CONFIG "$assigned_content")" + wg0="$(mosip_peer_for_secret CLUSTER_WIREGUARD_WG0 "$assigned_content")" + wg1="$(mosip_peer_for_secret CLUSTER_WIREGUARD_WG1 "$assigned_content")" + mark_env_peer "$tf" + mark_env_peer "$wg0" + mark_env_peer "$wg1" + [[ -n "$tf" && -n "$wg0" && -n "$wg1" ]] && reused="true" + fi + + if [[ -n "$FORCE_TF" ]]; then + [[ -n "$tf" && "$FORCE_TF" != "$tf" ]] \ + && { echo "ERROR: --tf-peer $FORCE_TF conflicts with existing $tf for $ENV_NAME" >&2; return 1; } + tf="$FORCE_TF" + CHOSEN["$tf"]=1 + fi + if [[ -n "$FORCE_WG0" ]]; then + [[ -n "$wg0" && "$FORCE_WG0" != "$wg0" ]] \ + && { echo "ERROR: --wg0-peer $FORCE_WG0 conflicts with existing $wg0 for $ENV_NAME" >&2; return 1; } + wg0="$FORCE_WG0" + CHOSEN["$wg0"]=1 + fi + if [[ -n "$FORCE_WG1" ]]; then + [[ -n "$wg1" && "$FORCE_WG1" != "$wg1" ]] \ + && { echo "ERROR: --wg1-peer $FORCE_WG1 conflicts with existing $wg1 for $ENV_NAME" >&2; return 1; } + wg1="$FORCE_WG1" + CHOSEN["$wg1"]=1 + fi + + next_free() { + local i + for ((i = 1; i <= MAX_PEERS; i++)); do + peer="peer${i}" + if [[ -n "${TAKEN[$peer]:-}" || -n "${CHOSEN[$peer]:-}" ]]; then + continue + fi + if require_peer_conf "$i"; then + echo "$peer" + return 0 + fi + echo "WARN: skipping $peer (no existing conf; trying next free slot)" >&2 + done + return 1 + } + + if [[ "$reused" != "true" ]]; then + if [[ -z "$tf" ]]; then tf="$(next_free)" || { echo "ERROR: no free peers in peer1..peer${MAX_PEERS}" >&2; return 1; }; CHOSEN["$tf"]=1; fi + if [[ -z "$wg0" ]]; then wg0="$(next_free)" || { echo "ERROR: no free peers in peer1..peer${MAX_PEERS}" >&2; return 1; }; CHOSEN["$wg0"]=1; fi + if [[ -z "$wg1" ]]; then wg1="$(next_free)" || { echo "ERROR: no free peers in peer1..peer${MAX_PEERS}" >&2; return 1; }; CHOSEN["$wg1"]=1; fi + fi + + for peer in "$tf" "$wg0" "$wg1"; do + [[ -n "${TAKEN[$peer]:-}" && -z "${ENV_PEERS[$peer]:-}" ]] \ + && { echo "ERROR: $peer already assigned in $ASSIGNED_FILE" >&2; return 1; } + [[ "$peer" =~ ^peer[0-9]+$ ]] || { echo "ERROR: invalid peer id $peer" >&2; return 1; } + n="${peer#peer}" + (( n >= 1 && n <= MAX_PEERS )) \ + || { echo "ERROR: $peer is outside peer1..peer${MAX_PEERS}" >&2; return 1; } + require_peer_conf "$n" || { echo "ERROR: missing existing conf for $peer" >&2; return 1; } + done + + [[ "$tf" != "$wg0" && "$tf" != "$wg1" && "$wg0" != "$wg1" ]] \ + || { echo "ERROR: peers must be distinct (tf=$tf wg0=$wg0 wg1=$wg1)" >&2; return 1; } + + [[ -z "${ENV_PEERS[$tf]:-}" ]] && record_tf="true" + [[ -z "${ENV_PEERS[$wg0]:-}" ]] && record_wg0="true" + [[ -z "${ENV_PEERS[$wg1]:-}" ]] && record_wg1="true" + + if [[ "$DRY_RUN" != "true" ]]; then + assigned_file_writable "$ASSIGNED_FILE" || return 1 + if [[ "$format" == "colon" ]]; then + [[ "$record_tf" == "true" ]] && record_assignment "$tf" "$ENV_NAME" "$format" "$ASSIGNED_FILE" + [[ "$record_wg0" == "true" ]] && record_assignment "$wg0" "$ENV_NAME" "$format" "$ASSIGNED_FILE" + [[ "$record_wg1" == "true" ]] && record_assignment "$wg1" "$ENV_NAME" "$format" "$ASSIGNED_FILE" + else + [[ "$record_tf" == "true" ]] && record_assignment "$tf" "${LABEL}(TF_WG_CONFIG)" "$format" "$ASSIGNED_FILE" + [[ "$record_wg0" == "true" ]] && record_assignment "$wg0" "${LABEL}(CLUSTER_WIREGUARD_WG0)" "$format" "$ASSIGNED_FILE" + [[ "$record_wg1" == "true" ]] && record_assignment "$wg1" "${LABEL}(CLUSTER_WIREGUARD_WG1)" "$format" "$ASSIGNED_FILE" + fi + sort_assigned_file "$ASSIGNED_FILE" || return 1 + fi + + printf 'ASSIGNED_FORMAT=%s\nREUSED=%s\nTF_PEER=%s\nWG0_PEER=%s\nWG1_PEER=%s\nRECORD_TF=%s\nRECORD_WG0=%s\nRECORD_WG1=%s\n' \ + "$format" "$reused" "$tf" "$wg0" "$wg1" "$record_tf" "$record_wg0" "$record_wg1" +} + +exec 9>"$LOCK_FILE" +if ! flock -w 120 9; then + echo "ERROR: timed out waiting for allocation lock ($LOCK_FILE)" >&2 + exit 1 +fi +run_allocation +REMOTE_ATOMIC_ALLOCATE +} + +atomic_rollback_assignments() { + local record_tf="$1" record_wg0="$2" record_wg1="$3" + [[ "$record_tf" == "true" || "$record_wg0" == "true" || "$record_wg1" == "true" ]] || return 0 + ssh_bash_stdin \ + "$ASSIGNED_FILE" \ + "$ASSIGN_LOCK_FILE" \ + "$TF_PEER" \ + "$WG0_PEER" \ + "$WG1_PEER" \ + "$record_tf" \ + "$record_wg0" \ + "$record_wg1" \ + <<'REMOTE_ROLLBACK_ASSIGNMENTS' +set -euo pipefail + +ASSIGNED_FILE="$1" +LOCK_FILE="$2" +TF_PEER="$3" +WG0_PEER="$4" +WG1_PEER="$5" +RECORD_TF="$6" +RECORD_WG0="$7" +RECORD_WG1="$8" + +exec 9>"$LOCK_FILE" || { echo "ERROR: cannot create allocation lock ($LOCK_FILE)" >&2; exit 1; } +if ! flock -w 120 9; then + echo "ERROR: timed out waiting for allocation lock ($LOCK_FILE)" >&2 + exit 1 +fi +wg_dir="$(dirname "$ASSIGNED_FILE")" +if [[ ! -w "$ASSIGNED_FILE" && ! -w "$wg_dir" ]]; then + echo "ERROR: cannot write $ASSIGNED_FILE (check ownership/permissions on $wg_dir)" >&2 + exit 1 +fi + +atomic_replace_file() { + local src="$1" dest="$2" + if ! mv -f "$src" "$dest"; then + rm -f "$src" + echo "ERROR: cannot update $dest (check ownership/permissions)" >&2 + return 1 + fi +} + +remove_peer_line() { + local peer="$1" file="$2" dir tmp + dir="$(dirname "$file")" + tmp="$(mktemp "$dir/.assigned.XXXXXX")" || { + echo "ERROR: cannot create temp file in $dir" >&2 + return 1 + } + awk -v p="$peer" '$1 !~ ("^" p ":?$")' "$file" > "$tmp" + atomic_replace_file "$tmp" "$file" +} + +[[ "$RECORD_TF" == "true" ]] && remove_peer_line "$TF_PEER" "$ASSIGNED_FILE" +[[ "$RECORD_WG0" == "true" ]] && remove_peer_line "$WG0_PEER" "$ASSIGNED_FILE" +[[ "$RECORD_WG1" == "true" ]] && remove_peer_line "$WG1_PEER" "$ASSIGNED_FILE" +REMOTE_ROLLBACK_ASSIGNMENTS +} + +rollback_published_secrets() { + local count="$1" i failed="false" + [[ "$count" -gt 0 ]] || return 0 + for ((i = 0; i < count; i++)); do + if gh secret delete "${SECRET_NAMES[$i]}" --env "$ENV_NAME_ENC" --repo "$REPO" 2>/dev/null; then + log "Deleted secret ${SECRET_NAMES[$i]}" + else + err "Failed to delete secret ${SECRET_NAMES[$i]}" + failed="true" + fi + done + [[ "$failed" != "true" ]] +} + +rollback_allocation_state() { + local published_count="$1" + err "Rolling back published secrets and assignments for $ENV_NAME ..." + if ! rollback_published_secrets "$published_count"; then + err "Skipping assignment rollback because at least one GitHub secret could not be deleted; keep $ASSIGNED_FILE reserved and clean up manually." + return 0 + fi + atomic_rollback_assignments "$RECORD_TF" "$RECORD_WG0" "$RECORD_WG1" \ + || err "Assignment rollback failed; fix $ASSIGNED_FILE manually for TF=$TF_PEER WG0=$WG0_PEER WG1=$WG1_PEER" +} + +if [[ "$DRY_RUN" != "true" ]]; then + log "Checking assigned.txt is writable on ${JUMPSERVER_HOST} ..." + if ! ssh_bash_stdin "$ASSIGNED_FILE" <<'REMOTE_WRITABLE_CHECK'; then +set -euo pipefail +ASSIGNED_FILE="$1" +wg_dir="$(dirname "$ASSIGNED_FILE")" +if [[ -e "$ASSIGNED_FILE" ]]; then + if [[ ! -w "$ASSIGNED_FILE" ]]; then + echo "ERROR: $ASSIGNED_FILE is not writable by $(whoami) ($(stat -c '%U:%G %a' "$ASSIGNED_FILE" 2>/dev/null || echo 'stat failed'))" >&2 + echo "Fix: sudo chown ubuntu:ubuntu '$ASSIGNED_FILE' && sudo chmod u+w '$ASSIGNED_FILE'" >&2 + exit 1 + fi +elif [[ ! -w "$wg_dir" ]]; then + echo "ERROR: cannot create $ASSIGNED_FILE (directory $wg_dir not writable by $(whoami))" >&2 + exit 1 +fi +REMOTE_WRITABLE_CHECK + die "assigned.txt is not writable on jumpserver (fix permissions before re-running)" + fi +fi + +log "Allocating peers on jumpserver under flock ($ASSIGN_LOCK_FILE) ..." +ALLOC_RESULT="$(atomic_allocate_peers)" || die "Peer allocation failed on jumpserver" +ASSIGNED_FORMAT="mosip" +REUSED="false" +TF_PEER="" +WG0_PEER="" +WG1_PEER="" +RECORD_TF="false" +RECORD_WG0="false" +RECORD_WG1="false" +while IFS= read -r line; do + [[ "$line" == *=* ]] || continue + case "${line%%=*}" in + ASSIGNED_FORMAT) ASSIGNED_FORMAT="${line#*=}" ;; + REUSED) REUSED="${line#*=}" ;; + TF_PEER) TF_PEER="${line#*=}" ;; + WG0_PEER) WG0_PEER="${line#*=}" ;; + WG1_PEER) WG1_PEER="${line#*=}" ;; + RECORD_TF) RECORD_TF="${line#*=}" ;; + RECORD_WG0) RECORD_WG0="${line#*=}" ;; + RECORD_WG1) RECORD_WG1="${line#*=}" ;; + esac +done <<<"$ALLOC_RESULT" +[[ -n "$TF_PEER" && -n "$WG0_PEER" && -n "$WG1_PEER" ]] \ + || die "Allocation returned incomplete peer set" + +if [[ "$REUSED" == "true" ]]; then + log "Reusing existing allocation for $ENV_NAME: TF=$TF_PEER WG0=$WG0_PEER WG1=$WG1_PEER" +elif [[ "$RECORD_TF" != "true" || "$RECORD_WG0" != "true" || "$RECORD_WG1" != "true" ]]; then + log "Resuming partial allocation for $ENV_NAME: TF=$TF_PEER WG0=$WG0_PEER WG1=$WG1_PEER" +elif [[ "$ASSIGNED_FORMAT" == "colon" ]]; then + log "Detected assigned.txt colon format (peerN: username)" +fi + +log "Allocation -> TF_WG_CONFIG=$TF_PEER | CLUSTER_WIREGUARD_WG0=$WG0_PEER | CLUSTER_WIREGUARD_WG1=$WG1_PEER" + +# ---- Fetch + transform each peer conf -------------------------------------- +transform_conf() { + # stdin: raw peer conf -> stdout: DNS removed, [Peer] AllowedIPs replaced + awk -v ips="$ALLOWED_IPS" ' + /^\[Peer\]/ { peer=1; print; next } + peer && /^[[:space:]]*AllowedIPs[[:space:]]*=/ { print "AllowedIPs = " ips; next } + /^[[:space:]]*DNS[[:space:]]*=/ { next } + { print } + ' +} + +validate_wireguard_conf() { + local label="$1" content="$2" + if [[ ${#content} -lt 80 ]]; then + err "$label WireGuard config too short (${#content} bytes); expected a full peer conf" + return 1 + fi + if ! grep -q '^\[Interface\]' <<<"$content"; then + err "$label WireGuard config missing [Interface] section" + return 1 + fi + if ! grep -q '^\[Peer\]' <<<"$content"; then + err "$label WireGuard config missing [Peer] section" + return 1 + fi + if ! grep -q '^[[:space:]]*PrivateKey[[:space:]]*=' <<<"$content"; then + err "$label WireGuard config missing PrivateKey" + return 1 + fi +} + +# gh secret set --body - stores the literal character "-" (1 byte), not stdin. +# Pass the conf via a temp file / stdin redirect instead. +# Use ENV_NAME_ENC for --env: gh CLI does not URL-encode env names (# truncates URLs). +publish_env_secret() { + local name="$1" content="$2" tmp rc + validate_wireguard_conf "$name" "$content" || return 1 + tmp="$(mktemp)" || return 1 + chmod 600 "$tmp" || { rm -f "$tmp"; return 1; } + if ! printf '%s\n' "$content" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + gh secret set "$name" --env "$ENV_NAME_ENC" --repo "$REPO" --app actions < "$tmp" + rc=$? + rm -f "$tmp" + return $rc +} + +fetch_and_transform() { + local peer="$1" raw + local conf="$CONFIG_DIR/$peer/$peer.conf" + if [[ "$DRY_RUN" == "true" ]] && ! peer_config_exists "$peer"; then + echo "[DRY RUN] peer config for $peer not present yet" + return 0 + fi + raw="$(ssh_cmd "cat $(remote_quote "$conf") 2>/dev/null || sudo cat $(remote_quote "$conf")" 2>/dev/null || true)" + [[ -n "$raw" ]] || die "Could not read $CONFIG_DIR/$peer/$peer.conf" + grep -q '^[[:space:]]*AllowedIPs' <<<"$raw" || die "$peer.conf has no AllowedIPs line" + transform_conf <<<"$raw" +} + +log "Fetching and transforming peer configs ..." +TF_CONF="$(fetch_and_transform "$TF_PEER")" +WG0_CONF="$(fetch_and_transform "$WG0_PEER")" +WG1_CONF="$(fetch_and_transform "$WG1_PEER")" +if [[ "$DRY_RUN" != "true" ]]; then + log "Transformed: stripped DNS, set AllowedIPs=${ALLOWED_IPS} on all three confs." +fi + +if [[ "$DRY_RUN" == "true" ]]; then + log "DRY RUN - would update $ASSIGNED_FILE under allocation lock:" + if [[ "$ASSIGNED_FORMAT" == "colon" ]]; then + [[ "$RECORD_TF" == "true" ]] && log " $TF_PEER: $ENV_NAME" + [[ "$RECORD_WG0" == "true" ]] && log " $WG0_PEER: $ENV_NAME" + [[ "$RECORD_WG1" == "true" ]] && log " $WG1_PEER: $ENV_NAME" + else + [[ "$RECORD_TF" == "true" ]] && log " $TF_PEER ${LABEL}(TF_WG_CONFIG)" + [[ "$RECORD_WG0" == "true" ]] && log " $WG0_PEER ${LABEL}(CLUSTER_WIREGUARD_WG0)" + [[ "$RECORD_WG1" == "true" ]] && log " $WG1_PEER ${LABEL}(CLUSTER_WIREGUARD_WG1)" + fi + log "DRY RUN - would create env '$ENV_NAME' and set 3 secrets (values not shown)." + if grep -q '^AllowedIPs' <<<"$TF_CONF" 2>/dev/null; then + log "DRY RUN - AllowedIPs in each conf: $(grep -h '^AllowedIPs' <<<"$TF_CONF" | head -1)" + fi + exit 0 +fi + +# ---- Create the GitHub environment + publish the three secrets ------------- +log "Ensuring GitHub environment '$ENV_NAME' exists ..." +if ! gh api --method PUT -H "Accept: application/vnd.github+json" \ + "repos/${REPO}/environments/${ENV_NAME_ENC}" >/dev/null; then + err "Failed creating environment '$ENV_NAME' in $REPO" + if [[ "$RECORD_TF" == "true" || "$RECORD_WG0" == "true" || "$RECORD_WG1" == "true" ]]; then + atomic_rollback_assignments "$RECORD_TF" "$RECORD_WG0" "$RECORD_WG1" \ + || err "Rollback failed; fix $ASSIGNED_FILE manually for TF=$TF_PEER WG0=$WG0_PEER WG1=$WG1_PEER" + fi + die "Environment creation failed" +fi + +log "Publishing environment secrets ..." +PEER_CONFS=("$TF_CONF" "$WG0_CONF" "$WG1_CONF") +PUBLISHED_SECRETS=0 +for i in "${!SECRET_NAMES[@]}"; do + log "Publishing ${SECRET_NAMES[$i]} (${#PEER_CONFS[$i]} bytes) ..." + if publish_env_secret "${SECRET_NAMES[$i]}" "${PEER_CONFS[$i]}"; then + PUBLISHED_SECRETS=$((PUBLISHED_SECRETS + 1)) + log "Published ${SECRET_NAMES[$i]}" + else + err "Failed to publish ${SECRET_NAMES[$i]} (${PUBLISHED_SECRETS}/3 succeeded)" + rollback_allocation_state "$PUBLISHED_SECRETS" + exit 1 + fi +done + +# ---- Mirror allocation into the repo tracker (for visibility/PR history) ---- +if [[ ! -f "$ALLOCATION_FILE" ]]; then + printf 'env_name\ttf_peer\twg0_peer\twg1_peer\tallocated_at\n' > "$ALLOCATION_FILE" +fi +exec 8>"${ALLOCATION_FILE}.lock" +if ! flock -w 30 8; then + die "Timed out waiting to update repo tracker lock ${ALLOCATION_FILE}.lock" +fi +TRACKER_TMP="$(mktemp "$(dirname "$ALLOCATION_FILE")/.wg-peer-allocation.XXXXXX")" +awk -F '\t' -v env="$ENV_NAME" 'NR == 1 || $1 != env' "$ALLOCATION_FILE" > "$TRACKER_TMP" +printf '%s\t%s\t%s\t%s\t%s\n' "$ENV_NAME" "$TF_PEER" "$WG0_PEER" "$WG1_PEER" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$TRACKER_TMP" +chmod --reference="$ALLOCATION_FILE" "$TRACKER_TMP" 2>/dev/null || true +mv "$TRACKER_TMP" "$ALLOCATION_FILE" +TRACKER_TMP="" + +log "Done. Environment '$ENV_NAME' onboarded with peers TF=$TF_PEER WG0=$WG0_PEER WG1=$WG1_PEER." +log "Server tracker: $ASSIGNED_FILE | repo tracker: $ALLOCATION_FILE (commit it)." diff --git a/.github/workflows/wg-onboard.yml b/.github/workflows/wg-onboard.yml new file mode 100644 index 000000000..0f283aea0 --- /dev/null +++ b/.github/workflows/wg-onboard.yml @@ -0,0 +1,179 @@ +name: WireGuard environment onboard/offboard + +# Self-service WireGuard onboarding and offboarding for an environment. +# Onboard: allocates free WireGuard peers from the jumpserver and publishes them +# as GitHub *environment* secrets (TF_WG_CONFIG, CLUSTER_WIREGUARD_WG0/WG1). +# Offboard: frees assigned.txt lines, deletes TF_WG_CONFIG / WG0 / WG1 secrets, +# keeps the GitHub environment object by default (set KEEP_ENVIRONMENT=false to delete it too). +# +# Requires .github/scripts/wg-env.sh. + +on: + workflow_dispatch: + inputs: + ACTION: + description: 'Onboard (allocate peers + secrets) or offboard (release peers + delete secrets)' + required: true + type: choice + options: + - onboard + - offboard + ENV_NAME: + description: 'Environment / branch name (GitHub environment name)' + required: true + type: string + JUMPSERVER_HOST: + description: 'Jumpserver / WireGuard VM public IP or DNS (SSH reachable)' + required: true + type: string + TICKET: + description: 'Onboard only: ticket id to record in assigned.txt (e.g. DSD-10264)' + required: false + type: string + WG_DIR: + description: 'WireGuard env dir on the VM' + required: false + type: string + default: /home/ubuntu/wireguard_env_2026 + ALLOWED_IPS: + description: 'Onboard only: AllowedIPs to set in each conf' + required: false + type: string + default: 172.31.0.0/16 + KEEP_ENVIRONMENT: + description: 'Offboard only: keep the GitHub environment object (WG secrets are always removed on offboard)' + required: false + type: boolean + default: true + DRY_RUN: + description: 'Print actions without writing secrets or assigned.txt' + required: false + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: wg-env-${{ inputs.ENV_NAME }} + cancel-in-progress: false + +jobs: + wg-env: + runs-on: [self-hosted, Linux, X64] + timeout-minutes: 20 + environment: ${{ inputs.ENV_NAME }} + steps: + - name: Validate required secrets + run: | + missing=() + [[ -z "${{ secrets.ACTION_PAT }}" ]] && missing+=(ACTION_PAT) + [[ -z "${{ secrets.MOSIP_AWS_PEM }}" ]] && missing+=(MOSIP_AWS_PEM) + if ((${#missing[@]})); then + echo "ERROR: Missing repository secrets: ${missing[*]}" + echo "Add them under Settings → Secrets and variables → Actions for ${GITHUB_REPOSITORY}" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Ensure GitHub CLI + run: | + if command -v gh >/dev/null 2>&1; then + gh --version + exit 0 + fi + GH_VERSION=2.63.2 + curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C "${RUNNER_TEMP}" + GH_BIN="${RUNNER_TEMP}/gh_${GH_VERSION}_linux_amd64/bin" + echo "$GH_BIN" >> "$GITHUB_PATH" + "$GH_BIN/gh" --version + + - name: Write SSH private key + env: + SSH_KEY: ${{ secrets.MOSIP_AWS_PEM }} + SSH_KEY_NAME: MOSIP_AWS_PEM + run: | + SSH_KEY_PATH="${RUNNER_TEMP}/jumpserver_key" + printf '%s\n' "$SSH_KEY" | tr -d '\r' > "$SSH_KEY_PATH" + chmod 600 "$SSH_KEY_PATH" + if ! ssh-keygen -l -f "$SSH_KEY_PATH" >/dev/null 2>&1; then + echo "ERROR: secret '$SSH_KEY_NAME' is not a valid private key (check format/newlines/CRLF)" + exit 1 + fi + echo "SSH_KEY_PATH=$SSH_KEY_PATH" >> "$GITHUB_ENV" + + - name: Run WireGuard onboard/offboard + env: + GH_TOKEN: ${{ secrets.ACTION_PAT }} + GITHUB_TOKEN: ${{ secrets.ACTION_PAT }} + INPUT_ACTION: ${{ inputs.ACTION }} + INPUT_ENV_NAME: ${{ inputs.ENV_NAME }} + INPUT_JUMPSERVER_HOST: ${{ inputs.JUMPSERVER_HOST }} + INPUT_WG_DIR: ${{ inputs.WG_DIR }} + INPUT_ALLOWED_IPS: ${{ inputs.ALLOWED_IPS }} + INPUT_TICKET: ${{ inputs.TICKET }} + INPUT_KEEP_ENVIRONMENT: ${{ inputs.KEEP_ENVIRONMENT }} + INPUT_DRY_RUN: ${{ inputs.DRY_RUN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + chmod +x .github/scripts/wg-env.sh + args=( + "$INPUT_ACTION" + --env "$INPUT_ENV_NAME" + --host "$INPUT_JUMPSERVER_HOST" + --ssh-key "$SSH_KEY_PATH" + --repo "$GITHUB_REPOSITORY" + --wg-dir "$INPUT_WG_DIR" + ) + if [[ "$INPUT_ACTION" == "onboard" ]]; then + args+=(--allowed-ips "$INPUT_ALLOWED_IPS") + [[ -n "$INPUT_TICKET" ]] && args+=(--ticket "$INPUT_TICKET") + fi + if [[ "$INPUT_ACTION" == "offboard" ]]; then + if [[ "$INPUT_KEEP_ENVIRONMENT" == "true" ]]; then + args+=(--keep-environment) + else + args+=(--delete-environment) + fi + fi + [[ "$INPUT_DRY_RUN" == "true" ]] && args+=(--dry-run) + .github/scripts/wg-env.sh "${args[@]}" + + - name: Cleanup SSH private key + if: always() + run: rm -f "${SSH_KEY_PATH:-}" + + - name: Commit updated peer allocation + if: ${{ inputs.DRY_RUN == false }} + env: + GH_TOKEN: ${{ secrets.ACTION_PAT }} + INPUT_ACTION: ${{ inputs.ACTION }} + INPUT_ENV_NAME: ${{ inputs.ENV_NAME }} + GIT_AUTHOR_NAME: ${{ github.actor }} + GIT_AUTHOR_EMAIL: ${{ github.actor }}@users.noreply.github.com + GIT_COMMITTER_NAME: ${{ github.actor }} + GIT_COMMITTER_EMAIL: ${{ github.actor }}@users.noreply.github.com + run: | + tracker=".github/scripts/wg-peer-allocation.tsv" + if git diff --quiet -- "$tracker"; then + echo "No allocation change to commit" + else + remote_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + if ! git pull --rebase --autostash "$remote_url" "${GITHUB_REF_NAME}"; then + echo "ERROR: Rebase failed (concurrent tracker update?). Resolve on ${GITHUB_REF_NAME} and re-run." + exit 1 + fi + git add "$tracker" + if [[ "$INPUT_ACTION" == "onboard" ]]; then + msg="wg: allocate peers for environment $INPUT_ENV_NAME" + else + msg="wg: offboard environment $INPUT_ENV_NAME" + fi + git commit -s -m "$msg" + git push "$remote_url" "HEAD:${GITHUB_REF_NAME}" + fi diff --git a/Helmsman/dsf/mosip-platform-java21/external-dsf.yaml b/Helmsman/dsf/mosip-platform-java21/external-dsf.yaml index 35b526519..25bc2e748 100644 --- a/Helmsman/dsf/mosip-platform-java21/external-dsf.yaml +++ b/Helmsman/dsf/mosip-platform-java21/external-dsf.yaml @@ -114,7 +114,7 @@ apps: databases.mosip_regprc.port: 5433 databases.mosip_pms.enabled: "true" - databases.mosip_pms.branch: "v1.2.2.3" + databases.mosip_pms.branch: "v1.2.2.2" databases.mosip_pms.host: "postgres.${domain_name}" databases.mosip_pms.port: 5433 diff --git a/Helmsman/dsf/mosip-platform-java21/mosip-dsf.yaml b/Helmsman/dsf/mosip-platform-java21/mosip-dsf.yaml index 90532ea67..e9c41265c 100644 --- a/Helmsman/dsf/mosip-platform-java21/mosip-dsf.yaml +++ b/Helmsman/dsf/mosip-platform-java21/mosip-dsf.yaml @@ -230,7 +230,7 @@ apps: set: # image.repository: "mosipdev/masterdata-loader" # image.tag: "release-1.3.x" - mosipDataGithubBranch: "v1.3.2-rc.1" + mosipDataGithubBranch: "release-1.3.x" mosipDataGithubRepo: "https://github.com/mosip/mosip-data" mosipDataXlsfolderPath: "/home/mosip/mosip-data/mosip_master/xlsx" db.host: "postgres.${domain_name}" @@ -550,7 +550,7 @@ apps: pms-partner: namespace: pms enabled: true - version: 12.2.3 + version: 12.2.2 chart: mosip/pms-partner set: # image.repository: "mosipid/partner-management-service" @@ -562,7 +562,7 @@ apps: pms-policy: namespace: pms enabled: true - version: 12.2.3 + version: 12.2.2 chart: mosip/pms-policy set: # image.repository: "mosipid/policy-management-service" @@ -574,7 +574,7 @@ apps: pmp-ui: namespace: pms enabled: false - version: 12.0.2 + version: 12.2.2 chart: mosip/pmp-ui set: image.repository: "mosipid/pmp-ui" @@ -1103,4 +1103,4 @@ apps: regclient.hostName: "api-internal.${domain_name}" istio.host: "regclient.${domain_name}" priority: -1 - timeout: 1200 \ No newline at end of file + timeout: 1200 diff --git a/Helmsman/dsf/mosip-platform-java21/testrigs-dsf.yaml b/Helmsman/dsf/mosip-platform-java21/testrigs-dsf.yaml index d0623b703..9d34763ea 100644 --- a/Helmsman/dsf/mosip-platform-java21/testrigs-dsf.yaml +++ b/Helmsman/dsf/mosip-platform-java21/testrigs-dsf.yaml @@ -22,7 +22,7 @@ apps: packetcreator: namespace: packetcreator enabled: true - version: 1.4.0 + version: 1.5.0 chart: mosip/packetcreator set: # image.repository: "mosipqa/dsl-packetcreator" @@ -129,7 +129,7 @@ apps: dslorchestrator: namespace: dslrig enabled: true - version: 1.4.0 + version: 1.5.0 chart: mosip/dslorchestrator set: # image.repository: "mosipqa/dsl-orchestrator" @@ -180,4 +180,4 @@ apps: priority: -1 hooks: preInstall: "$WORKDIR/hooks/uitestrig-setup.sh" - postInstall: "$WORKDIR/hooks/trigger-test-jobs.sh" \ No newline at end of file + postInstall: "$WORKDIR/hooks/trigger-test-jobs.sh" diff --git a/Helmsman/utils/config-server-values-java21.yaml b/Helmsman/utils/config-server-values-java21.yaml index fbacb9922..4795e104e 100644 --- a/Helmsman/utils/config-server-values-java21.yaml +++ b/Helmsman/utils/config-server-values-java21.yaml @@ -4,7 +4,7 @@ spring_profiles: # Based on the user requiremnt the number of multiple sources from where configuration needs to be pulled can be updated below as mentioned. - type: git uri: "https://github.com/mosip/mosip-config" - version: "v1.3.1-rc.1" + version: "dev-1" ## Folders within the base repo where properties may be found. searchFolders: "" private: false diff --git a/terraform/implementations/aws/infra/profiles/mosip/aws.tfvars b/terraform/implementations/aws/infra/profiles/mosip/aws.tfvars index f8dd08536..55dff8b5e 100644 --- a/terraform/implementations/aws/infra/profiles/mosip/aws.tfvars +++ b/terraform/implementations/aws/infra/profiles/mosip/aws.tfvars @@ -6,16 +6,16 @@ # ============================================================ # Environment name (infra component) -cluster_name = "" +cluster_name = "dev1" # MOSIP's domain (ex: sandbox.xyz.net) -cluster_env_domain = "" +cluster_env_domain = "dev1.mosip.net" # Email-ID will be used by certbot to notify SSL certificate expiry via email -mosip_email_id = "" +mosip_email_id = "ivan.meneges@technoforte.co.in" # SSH login key name for AWS node instances (ex: my-ssh-key) -ssh_key_name = "" +ssh_key_name = "mosip-aws" # The AWS region for resource creation aws_provider_region = "ap-south-1" @@ -24,16 +24,16 @@ aws_provider_region = "ap-south-1" # If empty, uses all available AZs in the region # Example: ["ap-south-1a", "ap-south-1b"] for specific AZs # Example: [] for all available AZs in the region -specific_availability_zones = [] +specific_availability_zones = ["ap-south-1b"] # The instance type for Kubernetes nodes (control plane, worker, etcd) -k8s_instance_type = "t3a.2xlarge" +k8s_instance_type = "m6a.2xlarge" # The instance type for Nginx server (load balancer) -nginx_instance_type = "t3a.2xlarge" +nginx_instance_type = "m6a.2xlarge" # The Route 53 hosted zone ID -zone_id = "" +zone_id = "Z090954828SJIEL6P5406" ## UBUNTU 24.04 # The Amazon Machine Image ID for the instances @@ -77,7 +77,7 @@ WIREGUARD_CIDR = "172.0.0.0/8" # Use your actual WireGuard VPN CIDR # Rancher Import URL # Rancher Import Configuration enable_rancher_import = true -rancher_import_url = "\"\"" +rancher_import_url = "\"kubectl apply -f https://rancher.mosip.net/v3/import/25vr9dfp4vv6v9qj42gxrhj2l7t5s4p7fzwzwnlxxj9hk7rkk9v5mw_c-m-czttcqpx.yaml\"" # DNS Records to map subdomain_public = ["resident", "prereg", "esignet", "healthservices", "signup"] @@ -93,10 +93,10 @@ postgresql_port = "5433" # MOSIP Infrastructure Repository Configuration mosip_infra_repo_url = "https://github.com/mosip/infra.git" -mosip_infra_branch = "release-0.2.0" +mosip_infra_branch = "dev-1" # VPC Configuration - Existing VPC to use (discovered by Name tag) -vpc_name = "" +vpc_name = "default" # ── ActiveMQ Configuration ───────────────────────────────────────────────────── # Set enable_activemq_setup = true AND nginx_node_ebs_volume_size_3 > 0 to @@ -108,4 +108,4 @@ nginx_node_ebs_volume_size_3 = 30 # Volume size in GB (e.g. 100); 0 = disabled activemq_storage_device = "/dev/nvme3n1" activemq_mount_point = "/srv/activemq" -activemq_nfs_allowed_hosts = "*" # Restrict to cluster CIDR in production e.g. "10.0.0.0/8" +activemq_nfs_allowed_hosts = "*" # Restrict to cluster CIDR in production e.g. "10.0.0.0/8"