#!/bin/bash # Montana — one-command Docker install on a clean Linux VPS. # # Result: a Helsinki-equivalent VPN-backend Montana node, fully containerised. # - montana-node container on host network, p2p :8444 # - xray container on host network, Reality VLESS-XTLS-Vision :443 # - nginx-decoy container on host network, plain HTTP :80 # - ufw host firewall opening 22/80/443/8444 # # Usage on a clean VPS (root): # curl -fsSL https://raw.githubusercontent.com/efir369999/Montana/main/Code/scripts/install-docker.sh \ # | sudo bash # # Pre-stageable secrets (optional — operator-supplied via scp BEFORE running): # /etc/montana-vpn/privkey Reality x25519 private key. Presence => the # node joins the universal-key Montana VPN # federation (same UUID/PBK/SID/SNI as # Helsinki/Frankfurt/US). Absence => generate # fresh standalone keys. # /etc/montana-vpn/orch-token Orchestrator admin secret. Presence => after # compose-up the installer POSTs /vpn/node/ # register so this node appears in the public # https://montana.quest/vpn/sub subscription. # # Operator metadata (used by orchestrator register payload; sensible defaults # from the VPS itself when omitted): # MONTANA_ALIAS= short lowercase alias # MONTANA_LABEL='Hostname Montana' human label (any UTF-8) # MONTANA_COUNTRY= e.g. AM, FI, DE # MONTANA_HOSTING= e.g. WorkTitans # MONTANA_COORDS='lat,lon' e.g. 40.18,44.51 # # Other environment knobs: # MONTANA_DECOY_HOST=www.googletagmanager.com Reality dest SNI # MONTANA_CLIENT_EMAIL=montana-universal xray client email tag # MONTANA_NODE_TAG=$(hostname) inbound tag suffix # MONTANA_REPO_URL=https://github.com/efir369999/Montana.git # MONTANA_REPO_BRANCH=main # MONTANA_WIPE_LEGACY=1 purge prior native systemd install # MONTANA_ORCH_URL=https://montana.quest/vpn/node # MONTANA_SKIP_VERIFY=0 skip post-install self-checks set -euo pipefail # ── configuration ─────────────────────────────────────────────────────────── REPO_URL="${MONTANA_REPO_URL:-https://github.com/efir369999/Montana.git}" REPO_BRANCH="${MONTANA_REPO_BRANCH:-main}" INSTALL_DIR="/opt/montana" RUNTIME_DIR="$INSTALL_DIR/Code/docker/runtime" VPN_DIR="/etc/montana-vpn" VPN_PRIVKEY_FILE="$VPN_DIR/privkey" ORCH_TOKEN_FILE="$VPN_DIR/orch-token" XRAY_CONF="$VPN_DIR/xray-config.json" NGX_CONF="$VPN_DIR/nginx-decoy.conf" DECOY_HTML="$VPN_DIR/decoy-index.html" DECOY_HOST="${MONTANA_DECOY_HOST:-www.googletagmanager.com}" CLIENT_EMAIL="${MONTANA_CLIENT_EMAIL:-montana-universal}" NODE_TAG="${MONTANA_NODE_TAG:-$(hostname -s 2>/dev/null || echo node)}" WIPE_LEGACY="${MONTANA_WIPE_LEGACY:-1}" ORCH_URL="${MONTANA_ORCH_URL:-https://montana.quest/vpn/node}" SKIP_VERIFY="${MONTANA_SKIP_VERIFY:-0}" # Universal Montana VPN client metadata — public, distributed in VLESS subs. UNIVERSAL_UUID="e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d" UNIVERSAL_SID="302805bc0c25e504" log() { printf '\033[1;32m[install-docker]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[install-docker]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[install-docker] ERROR:\033[0m %s\n' "$*" >&2; exit 1; } ok() { printf '\033[1;32m[verify ✓]\033[0m %s\n' "$*"; } bad() { printf '\033[1;31m[verify ✗]\033[0m %s\n' "$*" >&2; } retry() { local n="$1"; shift local i=1 while [ "$i" -le "$n" ]; do if "$@"; then return 0; fi warn "attempt $i/$n failed for: $*" i=$((i+1)); sleep $((i*3)) done return 1 } # ── 1. preconditions ───────────────────────────────────────────────────────── [ "$(id -u)" = "0" ] || die "root privileges required" [ -f /etc/os-release ] || die "/etc/os-release missing" . /etc/os-release OS_ID="${ID:-unknown}" case "$OS_ID" in ubuntu|debian) ;; *) die "unsupported OS: $OS_ID. Supported: ubuntu, debian" ;; esac log "OS: ${PRETTY_NAME:-$OS_ID}" # ── 2. wipe prior native systemd install if any ────────────────────────────── if [ "$WIPE_LEGACY" = "1" ]; then log "wiping any prior native systemd install of montana-node / xray / nginx..." systemctl stop montana-node xray nginx 2>/dev/null || true systemctl disable montana-node xray nginx 2>/dev/null || true rm -f /etc/systemd/system/montana-node.service \ /etc/systemd/system/xray.service /etc/systemd/system/xray@.service rm -rf /etc/systemd/system/montana-node.service.d \ /etc/systemd/system/xray.service.d /etc/systemd/system/xray@.service.d systemctl daemon-reload 2>/dev/null || true systemctl reset-failed 2>/dev/null || true # Native xray uninstall — only if there's something to remove. if [ -x /usr/local/bin/xray ] && [ -f /etc/systemd/system/xray.service ]; then bash -c "$(curl -fsSL https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" \ @ remove --purge >/dev/null 2>&1 || true fi rm -f /usr/local/bin/xray /usr/local/bin/xctl rm -rf /usr/local/etc/xray /usr/local/share/xray /var/log/xray if dpkg -l 2>/dev/null | grep -qE '^ii nginx'; then DEBIAN_FRONTEND=noninteractive apt-get remove --purge -y nginx nginx-core nginx-common nginx-full >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive apt-get autoremove -y >/dev/null 2>&1 || true fi rm -rf /etc/nginx /var/www/decoy if [ -d /var/lib/montana ]; then BK="/root/montana-pre-docker-backup-$(date +%s)" mkdir -p "$BK" cp -a /var/lib/montana "$BK/" 2>/dev/null || true cp -a /etc/montana "$BK/" 2>/dev/null || true log "backed up legacy data to $BK" fi rm -rf /var/lib/montana /etc/montana id montana >/dev/null 2>&1 && userdel -r montana 2>/dev/null || true rm -f /usr/local/bin/montana-node log "legacy wipe complete" fi # ── 3. apt deps + docker + compose plugin ──────────────────────────────────── log "installing apt deps (curl, ca-certificates, gnupg, openssl, jq, ufw, git)..." export DEBIAN_FRONTEND=noninteractive retry 3 apt-get update -qq retry 3 apt-get install -y -qq curl ca-certificates gnupg openssl jq ufw git lsb-release >/dev/null if ! command -v docker >/dev/null 2>&1; then log "installing Docker Engine + compose plugin via official apt repo..." install -m 0755 -d /etc/apt/keyrings retry 3 bash -c "curl -fsSL https://download.docker.com/linux/${OS_ID}/gpg \ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg" chmod a+r /etc/apt/keyrings/docker.gpg CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME:-bookworm}")" echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/${OS_ID} ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list retry 3 apt-get update -qq retry 3 apt-get install -y -qq docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin >/dev/null fi systemctl enable --now docker >/dev/null 2>&1 || true docker --version docker compose version # ── 4. firewall ────────────────────────────────────────────────────────────── log "configuring ufw (22, 80, 443, 8444)..." ufw allow 22/tcp comment 'SSH' >/dev/null 2>&1 || true ufw allow 80/tcp comment 'decoy nginx' >/dev/null 2>&1 || true ufw allow 443/tcp comment 'VLESS+TCP+Reality+Vision' >/dev/null 2>&1 || true ufw allow 8444/tcp comment 'Montana p2p Noise_PQ XX' >/dev/null 2>&1 || true yes | ufw --force enable >/dev/null 2>&1 || true # ── 5. clone repo ──────────────────────────────────────────────────────────── if [ -d "$INSTALL_DIR/.git" ]; then log "updating repo at $INSTALL_DIR..." (cd "$INSTALL_DIR" && retry 3 git fetch --depth 1 origin "$REPO_BRANCH" && \ git reset --hard "origin/$REPO_BRANCH") else log "cloning $REPO_URL → $INSTALL_DIR..." retry 3 git clone --branch "$REPO_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR" fi [ -d "$RUNTIME_DIR" ] || die "expected $RUNTIME_DIR not found — repo layout drifted" # ── 6. xray config — universal (privkey pre-staged) or fresh ──────────────── mkdir -p "$VPN_DIR" && chmod 0700 "$VPN_DIR" install -m 0644 "$RUNTIME_DIR/nginx-decoy.conf" "$NGX_CONF" install -m 0644 "$RUNTIME_DIR/decoy-index.html" "$DECOY_HTML" if [ -s "$VPN_PRIVKEY_FILE" ]; then VPN_MODE=universal PRIV="$(tr -d ' \n\r' < "$VPN_PRIVKEY_FILE")" UUID="$UNIVERSAL_UUID" SID="$UNIVERSAL_SID" log "VPN mode: universal (privkey pre-staged at $VPN_PRIVKEY_FILE)" else VPN_MODE=fresh log "VPN mode: fresh keys (standalone Reality endpoint, not in Montana federation)" KEYS="$(docker run --rm teddysun/xray:26.2.6 xray x25519 2>&1 || true)" PRIV="$(echo "$KEYS" | awk -F': ' '/Private[ _]key:|PrivateKey:/ {print $NF; exit}' | tr -d ' \r')" PBK_FRESH="$(echo "$KEYS" | awk -F': ' '/Password|ublic/ {print $NF; exit}' | tr -d ' \r')" [ -n "$PRIV" ] && [ -n "$PBK_FRESH" ] || die "failed to derive fresh x25519 keypair from xray container" UUID="$(cat /proc/sys/kernel/random/uuid)" SID="$(openssl rand -hex 8)" install -m 0600 /dev/stdin "$VPN_PRIVKEY_FILE" <<<"$PRIV" fi PBK="$(docker run --rm teddysun/xray:26.2.6 xray x25519 -i "$PRIV" 2>&1 \ | awk -F': ' '/Password|ublic/ {print $NF; exit}' | tr -d ' \r')" [ -n "$PBK" ] || die "failed to derive PublicKey from PrivateKey" sed \ -e "s|{{CLIENT_UUID}}|$UUID|g" \ -e "s|{{CLIENT_EMAIL}}|$CLIENT_EMAIL|g" \ -e "s|{{NODE_TAG}}|$NODE_TAG|g" \ -e "s|{{DECOY_HOST}}|$DECOY_HOST|g" \ -e "s|{{REALITY_PRIVATE_KEY}}|$PRIV|g" \ -e "s|{{REALITY_SHORT_ID}}|$SID|g" \ "$RUNTIME_DIR/xray-config.json.template" > "$XRAY_CONF" chmod 0640 "$XRAY_CONF" # ── 7. compose up (build + start) ──────────────────────────────────────────── cd "$RUNTIME_DIR" log "building montana-node image and bringing the stack up (build is 10-30 min on small VPS)..." docker compose down --remove-orphans >/dev/null 2>&1 || true docker compose up -d --build 2>&1 | tee /var/log/montana-compose.log | tail -200 # ── 8. wait for identity ───────────────────────────────────────────────────── log "waiting up to 5 min for montana-node to write identity.bin..." i=0 while [ "$i" -lt 60 ]; do if docker exec montana-node test -f /var/lib/montana/identity.bin 2>/dev/null; then break fi i=$((i+1)); sleep 5 done docker exec montana-node test -f /var/lib/montana/identity.bin \ || die "identity.bin not created after 5 min — inspect: docker logs montana-node" # ── 9. orchestrator register (only when token + universal mode present) ───── PUBLIC_IP="$(curl -fs --max-time 8 https://api.ipify.org || echo '')" ORCH_RESP='' if [ -s "$ORCH_TOKEN_FILE" ] && [ "$VPN_MODE" = "universal" ] && [ -n "$PUBLIC_IP" ]; then TOKEN="$(tr -d ' \n\r' < "$ORCH_TOKEN_FILE")" ALIAS="${MONTANA_ALIAS:-$NODE_TAG}" LABEL="${MONTANA_LABEL:-${ALIAS^} Montana}" COUNTRY="${MONTANA_COUNTRY:-XX}" HOSTING="${MONTANA_HOSTING:-unknown}" COORDS="${MONTANA_COORDS:-0,0}" LAT="$(echo "$COORDS" | cut -d, -f1)" LON="$(echo "$COORDS" | cut -d, -f2)" log "registering with orchestrator at $ORCH_URL/register (alias=$ALIAS, country=$COUNTRY)..." # Give xray a moment so the Reality probe by the orchestrator succeeds. sleep 4 PAYLOAD=$(jq -nc \ --arg alias "$ALIAS" --arg ip "$PUBLIC_IP" --arg country "$COUNTRY" \ --arg hosting "$HOSTING" --arg label "$LABEL" --argjson lat "$LAT" --argjson lon "$LON" \ --arg pbk "$PBK" --arg uuid "$UUID" --arg sid "$SID" --arg secret "$TOKEN" \ '{alias:$alias,ip:$ip,country:$country,hosting:$hosting,label:$label,coords:[$lat,$lon],reality_pbk:$pbk,reality_uuid:$uuid,reality_sid:$sid,secret:$secret}') ORCH_RESP="$(curl -sk --max-time 20 -X POST -H 'Content-Type: application/json' -d "$PAYLOAD" "$ORCH_URL/register" || true)" log "orchestrator response: $ORCH_RESP" fi # ── 10. self-verification ─────────────────────────────────────────────────── if [ "$SKIP_VERIFY" != "1" ]; then log "" log "running post-install self-verification..." PASS=0; FAIL=0 vcheck() { if eval "$1"; then ok "$2"; PASS=$((PASS+1)); else bad "$2"; FAIL=$((FAIL+1)); fi; } vcheck "docker ps --format '{{.Names}} {{.Status}}' | grep -q 'montana-node.*healthy\|montana-node.*Up'" \ "container montana-node up" vcheck "docker ps --format '{{.Names}} {{.Status}}' | grep -q 'montana-xray.*Up'" \ "container montana-xray up" vcheck "docker ps --format '{{.Names}} {{.Status}}' | grep -q 'montana-nginx-decoy.*Up'" \ "container montana-nginx-decoy up" # outbound peer TCP probe — bootstrap peers from genesis manifest if [ -f "$INSTALL_DIR/Code/scripts/genesis-manifest.json" ]; then PEER_HOSTS="$(jq -r '.peers[] | .multiaddr' "$INSTALL_DIR/Code/scripts/genesis-manifest.json" \ | sed -nE 's|/ip4/([0-9.]+)/tcp/([0-9]+)|\1 \2|p')" while read -r ph pp; do [ -z "$ph" ] && continue vcheck "timeout 5 bash -c '/dev/null" \ "peer TCP reachable: $ph:$pp" done <<<"$PEER_HOSTS" fi # local Reality TLS handshake to :443 vcheck "echo Q | timeout 8 openssl s_client -connect 127.0.0.1:443 -servername '$DECOY_HOST' -brief 2>&1 | grep -q 'CONNECTION ESTABLISHED'" \ "local TLS handshake :443 via Reality cover SNI" # decoy :80 vcheck "curl -sf --max-time 8 -o /dev/null 'http://127.0.0.1/'" "decoy :80 returns 200" # ESTABLISHED peer connections from montana-node EST="$(ss -tnp 2>/dev/null | grep montana-node | wc -l)" vcheck "[ '$EST' -ge 1 ]" "at least 1 ESTABLISHED p2p connection (got $EST)" # /vpn/sub membership (universal mode only — fresh keys aren't aggregated) if [ "$VPN_MODE" = "universal" ] && [ -n "$PUBLIC_IP" ]; then SUB="$(curl -sk --max-time 10 "${ORCH_URL%/node}/sub" | base64 -d 2>/dev/null || true)" ALIAS_LOWER="$(echo "${MONTANA_ALIAS:-$NODE_TAG}" | tr '[:upper:]' '[:lower:]')" # match either the alias label or this server's public IP appearing in any VLESS URL vcheck "echo \"\$SUB\" | grep -qi -E '${ALIAS_LOWER}\\.montana\\.quest|${PUBLIC_IP//./\\.}'" \ "node appears in https://montana.quest/vpn/sub subscription" fi log "" if [ "$FAIL" = "0" ]; then log "self-verification: $PASS/$PASS checks passed" else warn "self-verification: $PASS passed / $FAIL failed — review checks above" fi fi # ── 11. final report ──────────────────────────────────────────────────────── log "" log "================================================================" log " INSTALL COMPLETE" log "================================================================" log "" log "Containers:" docker compose ps --format 'table {{.Name}}\t{{.Status}}' 2>/dev/null || docker compose ps log "" log "Montana node identity (24-word mnemonic — write it down NOW):" echo "----------------------------------------------------------------" docker exec montana-node cat /var/lib/montana/mnemonic.txt 2>/dev/null \ || warn "mnemonic.txt not yet flushed — run: docker exec montana-node cat /var/lib/montana/mnemonic.txt" echo "----------------------------------------------------------------" log "" log "VPN client subscription (VLESS Reality):" echo "vless://${UUID}@${PUBLIC_IP:-}:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${DECOY_HOST}&fp=chrome&pbk=${PBK}&sid=${SID}&type=tcp#montana-${NODE_TAG}" log "" if [ -n "$ORCH_RESP" ]; then log "Orchestrator: $ORCH_RESP" log "Public subscription (decoded): curl -sk ${ORCH_URL%/node}/sub | base64 -d" fi log "" log "Useful commands:" log " docker compose -f $RUNTIME_DIR/docker-compose.yml ps" log " docker logs -f montana-node" log " docker exec montana-node /usr/local/bin/montana-node status --data-dir /var/lib/montana" log " docker logs montana-xray" log " docker compose -f $RUNTIME_DIR/docker-compose.yml down # stop" log " docker compose -f $RUNTIME_DIR/docker-compose.yml down -v # stop + wipe identity" log "" log "Node lifecycle:" log " Phase 1: CandidateVdf — sequential SHA-256 chain to vdf_chain_length >= τ₂" log " Phase 2: Registered — NodeRegistration via apply_proposal on next selection window" log " Phase 3: Active — emission 13 Ɉ per window via apply_proposal"