montana/Montana-Protocol/Code/scripts/install-docker.sh
2026-05-26 21:14:51 +03:00

344 lines
17 KiB
Bash
Executable File

#!/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=<hostname-short> short lowercase alias
# MONTANA_LABEL='Hostname Montana' human label (any UTF-8)
# MONTANA_COUNTRY=<two-letter ISO> e.g. AM, FI, DE
# MONTANA_HOSTING=<provider name> 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/tcp/$ph/$pp' 2>/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:-<host-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"