#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_MANIFEST="${SCRIPT_DIR}/manifest.example.json" DEFAULT_SOURCE_POLICY="${SCRIPT_DIR}/source_policy.production.json" MANIFEST="${DEFAULT_MANIFEST}" BIN_ROOT="/opt/selective-vpn/bin" TARGET="" COMPONENTS_RAW="" SOURCE_POLICY="" SIGNATURE_MODE_OVERRIDE="" ROLLOUT_STAGE="stable" COHORT_ID="" FORCE_ROLLOUT=0 DRY_RUN=0 usage() { cat <<'EOF' Usage: update.sh [--manifest PATH] [--bin-root DIR] [--target OS-ARCH] [--component NAME[,NAME...]] [--source-policy PATH] [--signature-mode off|optional|required] [--rollout-stage stable|canary|any] [--cohort-id 0..99] [--force-rollout] [--canary] [--dry-run] Description: Manual pinned updater for transport companion binaries in runtime_mode=exec. Reads versions/urls/checksums from manifest and atomically switches active symlinks in BIN_ROOT. Examples: ./scripts/transport-packaging/update.sh --manifest ./manifest.json --component singbox ./scripts/transport-packaging/update.sh --manifest ./manifest.json --target linux-amd64 --dry-run ./scripts/transport-packaging/update.sh --manifest ./manifest.production.json --source-policy ./source_policy.production.json ./scripts/transport-packaging/update.sh --manifest /path/to/manifest-with-canary.json --rollout-stage canary EOF } normalize_os() { local raw="$1" raw="$(echo "$raw" | tr '[:upper:]' '[:lower:]')" case "$raw" in linux) echo "linux" ;; darwin) echo "darwin" ;; *) echo "$raw" ;; esac } normalize_arch() { local raw="$1" raw="$(echo "$raw" | tr '[:upper:]' '[:lower:]')" case "$raw" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; armv7l|armv7) echo "armv7" ;; *) echo "$raw" ;; esac } require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "[transport-update] missing required command: ${cmd}" >&2 exit 1 fi } history_append_if_changed() { local history_file="$1" local line="$2" local target="$3" local last_target="" if [[ -f "$history_file" ]]; then last_target="$(tail -n1 "$history_file" | awk -F'|' '{print $4}')" fi if [[ "$last_target" == "$target" ]]; then return 0 fi mkdir -p "$(dirname "$history_file")" echo "$line" >> "$history_file" } trim_spaces() { echo "$1" | xargs } normalize_signature_mode() { local raw raw="$(echo "$1" | tr '[:upper:]' '[:lower:]' | xargs)" case "$raw" in ""|off|optional|required) echo "$raw" ;; *) echo "[transport-update] invalid signature mode: ${raw}" >&2 exit 1 ;; esac } compute_cohort_id() { local component="$1" local target="$2" local seed="" if [[ -r /etc/machine-id ]]; then seed="$(tr -d '\n' 100: fail(f"component {name} target {target_key} rollout.percent must be 0..100") if sig_type and not sig_url: fail(f"component {name} target {target_key} signature.url is required when signature.type is set") if sig_sha256: if len(sig_sha256) != 64 or any(c not in "0123456789abcdef" for c in sig_sha256): fail(f"component {name} target {target_key} signature.sha256 is invalid") if sig_public_key_path and not sig_type: fail(f"component {name} target {target_key} signature.type is required when signature.public_key_path is set") rows.append(( name, binary_name, version, url, sha256, asset_type, asset_binary_path, rollout_stage, str(rollout_percent), sig_type, sig_url, sig_sha256, sig_public_key_path )) seen.add(name) if wanted: missing = sorted(wanted - seen) if missing: fail("missing/enabled=false components in manifest: " + ",".join(missing)) for row in rows: print("\x1f".join(row)) PY } verify_asset_signature() { local component="$1" local asset_path="$2" local tmp_dir="$3" local sig_mode="$4" local sig_type="$5" local sig_url="$6" local sig_sha256="$7" local sig_public_key_path="$8" if [[ "$sig_mode" == "off" ]]; then return 0 fi if [[ -z "$sig_type" || -z "$sig_url" || -z "$sig_public_key_path" ]]; then if [[ "$sig_mode" == "required" ]]; then echo "[transport-update] ${component}: signature is required, but signature fields are incomplete" >&2 return 1 fi echo "[transport-update] WARN ${component}: signature_mode=${sig_mode}, signature metadata is incomplete, skip signature check" return 0 fi if [[ ! -f "$sig_public_key_path" ]]; then if [[ "$sig_mode" == "required" ]]; then echo "[transport-update] ${component}: signature public key not found: ${sig_public_key_path}" >&2 return 1 fi echo "[transport-update] WARN ${component}: signature public key not found: ${sig_public_key_path}, skip signature check" return 0 fi case "$sig_type" in openssl-sha256) require_cmd openssl ;; *) if [[ "$sig_mode" == "required" ]]; then echo "[transport-update] ${component}: unsupported signature type: ${sig_type}" >&2 return 1 fi echo "[transport-update] WARN ${component}: unsupported signature type: ${sig_type}, skip signature check" return 0 ;; esac local sig_file="${tmp_dir}/asset.sig" echo "[transport-update] ${component}: downloading signature ${sig_url}" curl -fsSL "$sig_url" -o "$sig_file" if [[ -n "$sig_sha256" ]]; then echo "${sig_sha256} ${sig_file}" | sha256sum -c - >/dev/null echo "[transport-update] ${component}: signature checksum ok" fi if ! openssl dgst -sha256 -verify "$sig_public_key_path" -signature "$sig_file" "$asset_path" >/dev/null 2>&1; then echo "[transport-update] ${component}: signature verification failed (${sig_type})" >&2 return 1 fi echo "[transport-update] ${component}: signature verified (${sig_type})" } download_install_binary() { local component="$1" local binary_name="$2" local version="$3" local url="$4" local sha256="$5" local asset_type="$6" local asset_binary_path="$7" local sig_mode="$8" local sig_type="$9" local sig_url="${10}" local sig_sha256="${11}" local sig_public_key_path="${12}" local release_dir="${13}" local release_binary="${14}" if [[ -x "$release_binary" ]]; then echo "[transport-update] ${component}: release already present ${release_binary}" return 0 fi if [[ "$DRY_RUN" -eq 1 ]]; then echo "[transport-update] DRY-RUN ${component}: download ${url} -> ${release_binary}" if [[ "$sig_mode" != "off" ]]; then echo "[transport-update] DRY-RUN ${component}: signature_mode=${sig_mode} signature_type=${sig_type:-none}" fi return 0 fi local tmp_dir tmp_dir="$(mktemp -d)" local asset="${tmp_dir}/asset" local unpack="${tmp_dir}/unpack" mkdir -p "$release_dir" "$unpack" echo "[transport-update] ${component}: downloading ${url}" curl -fsSL "$url" -o "$asset" echo "${sha256} ${asset}" | sha256sum -c - >/dev/null echo "[transport-update] ${component}: checksum ok" verify_asset_signature "$component" "$asset" "$tmp_dir" "$sig_mode" "$sig_type" "$sig_url" "$sig_sha256" "$sig_public_key_path" local source_binary="" case "$asset_type" in raw) source_binary="$asset" ;; tar.gz) require_cmd tar tar -xzf "$asset" -C "$unpack" source_binary="${unpack}/${asset_binary_path}" ;; zip) require_cmd unzip unzip -q "$asset" -d "$unpack" source_binary="${unpack}/${asset_binary_path}" ;; *) rm -rf "$tmp_dir" echo "[transport-update] ${component}: unsupported asset_type ${asset_type}" >&2 return 1 ;; esac if [[ ! -f "$source_binary" ]]; then rm -rf "$tmp_dir" echo "[transport-update] ${component}: binary not found in asset: ${source_binary}" >&2 return 1 fi install -m 0755 "$source_binary" "$release_binary" rm -rf "$tmp_dir" echo "[transport-update] ${component}: installed ${release_binary}" } while [[ $# -gt 0 ]]; do case "$1" in --manifest) MANIFEST="${2:-}" shift 2 ;; --bin-root) BIN_ROOT="${2:-}" shift 2 ;; --target) TARGET="${2:-}" shift 2 ;; --component) COMPONENTS_RAW="${2:-}" shift 2 ;; --source-policy) SOURCE_POLICY="${2:-}" shift 2 ;; --signature-mode) SIGNATURE_MODE_OVERRIDE="${2:-}" shift 2 ;; --rollout-stage) ROLLOUT_STAGE="${2:-}" shift 2 ;; --canary) ROLLOUT_STAGE="canary" shift ;; --cohort-id) COHORT_ID="${2:-}" shift 2 ;; --force-rollout) FORCE_ROLLOUT=1 shift ;; --dry-run) DRY_RUN=1 shift ;; -h|--help) usage exit 0 ;; *) echo "[transport-update] unknown argument: $1" >&2 usage >&2 exit 1 ;; esac done require_cmd curl require_cmd sha256sum require_cmd python3 require_cmd install require_cmd mktemp require_cmd readlink require_cmd ln require_cmd awk require_cmd tail if [[ ! -f "$MANIFEST" ]]; then echo "[transport-update] manifest not found: ${MANIFEST}" >&2 exit 1 fi if [[ -z "$SOURCE_POLICY" && "$(basename "$MANIFEST")" == "manifest.production.json" && -f "$DEFAULT_SOURCE_POLICY" ]]; then SOURCE_POLICY="$DEFAULT_SOURCE_POLICY" fi if [[ -n "$SOURCE_POLICY" && ! -f "$SOURCE_POLICY" ]]; then echo "[transport-update] source policy not found: ${SOURCE_POLICY}" >&2 exit 1 fi SIGNATURE_MODE_OVERRIDE="$(normalize_signature_mode "$SIGNATURE_MODE_OVERRIDE")" ROLLOUT_STAGE="$(echo "$ROLLOUT_STAGE" | tr '[:upper:]' '[:lower:]' | xargs)" case "$ROLLOUT_STAGE" in stable|canary|any) ;; *) echo "[transport-update] rollout stage must be stable|canary|any" >&2 exit 1 ;; esac if [[ -n "$COHORT_ID" ]]; then if [[ ! "$COHORT_ID" =~ ^[0-9]+$ ]]; then echo "[transport-update] cohort id must be integer 0..99" >&2 exit 1 fi if (( COHORT_ID < 0 || COHORT_ID > 99 )); then echo "[transport-update] cohort id must be in range 0..99" >&2 exit 1 fi fi if [[ -z "$TARGET" ]]; then TARGET="$(normalize_os "$(uname -s)")-$(normalize_arch "$(uname -m)")" fi echo "[transport-update] manifest=${MANIFEST}" echo "[transport-update] bin_root=${BIN_ROOT}" echo "[transport-update] target=${TARGET}" if [[ -n "$COMPONENTS_RAW" ]]; then echo "[transport-update] components=${COMPONENTS_RAW}" fi if [[ -n "$SOURCE_POLICY" ]]; then echo "[transport-update] source_policy=${SOURCE_POLICY}" fi echo "[transport-update] rollout_stage=${ROLLOUT_STAGE}" if [[ -n "$COHORT_ID" ]]; then echo "[transport-update] cohort_id=${COHORT_ID}" fi if [[ "$FORCE_ROLLOUT" -eq 1 ]]; then echo "[transport-update] force_rollout=true" fi if [[ -n "$SIGNATURE_MODE_OVERRIDE" ]]; then echo "[transport-update] signature_mode_override=${SIGNATURE_MODE_OVERRIDE}" fi if [[ "$DRY_RUN" -eq 1 ]]; then echo "[transport-update] mode=dry-run" fi mapfile -t ROWS < <(manifest_rows) if [[ "${#ROWS[@]}" -eq 0 ]]; then echo "[transport-update] no enabled components selected" >&2 exit 1 fi mkdir -p "$BIN_ROOT" "$BIN_ROOT/releases" "$BIN_ROOT/.packaging" for row in "${ROWS[@]}"; do IFS=$'\x1f' read -r component binary_name version url sha256 asset_type asset_binary_path rollout_stage rollout_percent sig_type sig_url sig_sha256 sig_public_key_path <<< "$row" release_dir="${BIN_ROOT}/releases/${component}/${version}" release_binary="${release_dir}/${binary_name}" active_link="${BIN_ROOT}/${binary_name}" history_file="${BIN_ROOT}/.packaging/${component}.history" sig_mode="$(evaluate_policy_and_signature_mode "$component" "$url" "$sig_url" "$sig_type")" sig_mode="$(trim_spaces "$sig_mode")" sig_mode="$(normalize_signature_mode "$sig_mode")" if [[ "$sig_mode" == "required" && ( -z "$sig_type" || -z "$sig_url" || -z "$sig_public_key_path" ) ]]; then echo "[transport-update] ${component}: signature_mode=required but signature metadata is incomplete" >&2 exit 1 fi if [[ "$ROLLOUT_STAGE" != "any" && "$rollout_stage" != "$ROLLOUT_STAGE" ]]; then echo "[transport-update] ${component}: skip rollout stage=${rollout_stage} (requested ${ROLLOUT_STAGE})" continue fi effective_cohort="$COHORT_ID" if [[ -z "$effective_cohort" ]]; then effective_cohort="$(compute_cohort_id "$component" "$TARGET")" fi if [[ "$FORCE_ROLLOUT" -ne 1 && "$rollout_percent" -lt 100 && "$effective_cohort" -ge "$rollout_percent" ]]; then echo "[transport-update] ${component}: skip rollout percent=${rollout_percent}% cohort=${effective_cohort}" continue fi echo "[transport-update] ${component}: version=${version} binary=${binary_name}" download_install_binary \ "$component" "$binary_name" "$version" "$url" "$sha256" "$asset_type" "$asset_binary_path" \ "$sig_mode" "$sig_type" "$sig_url" "$sig_sha256" "$sig_public_key_path" \ "$release_dir" "$release_binary" prev_target="" if [[ -L "$active_link" || -e "$active_link" ]]; then prev_target="$(readlink -f "$active_link" || true)" fi if [[ "$DRY_RUN" -eq 1 ]]; then echo "[transport-update] DRY-RUN ${component}: switch ${active_link} -> ${release_binary}" continue fi if [[ ! -x "$release_binary" ]]; then echo "[transport-update] ${component}: installed binary is not executable: ${release_binary}" >&2 exit 1 fi if [[ -n "$prev_target" && "$prev_target" != "$release_binary" && ! -f "$history_file" ]]; then history_append_if_changed "$history_file" "$(date -u +%Y-%m-%dT%H:%M:%SZ)|${binary_name}|preexisting|${prev_target}" "$prev_target" fi ln -sfn "$release_binary" "$active_link" history_append_if_changed "$history_file" "$(date -u +%Y-%m-%dT%H:%M:%SZ)|${binary_name}|${version}|${release_binary}" "$release_binary" echo "[transport-update] ${component}: active -> ${release_binary}" done echo "[transport-update] done"