#!/usr/bin/env bash set -euo pipefail if [[ $(id -u) -ne 0 ]]; then echo "This script must be run as root (use sudo)." >&2 exit 1 fi if ! command -v lsb_release >/dev/null 2>&1; then apt-get update -y apt-get install -y lsb-release >/dev/null fi UBUNTU_CODENAME=$(lsb_release -sc) UBUNTU_MAJOR=$(lsb_release -rs | cut -d'.' -f1) if [[ "$UBUNTU_MAJOR" != "22" ]]; then echo "Warning: this script targets Ubuntu 22.04. Detected $(lsb_release -ds)." >&2 read -r -p "Continue anyway? [y/N]: " _cont _cont=${_cont:-N} if [[ ! $_cont =~ ^[Yy]$ ]]; then exit 1 fi fi SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) BACKEND_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) PROJECT_ROOT=$(cd "$BACKEND_ROOT/.." && pwd) CONFIGS_DIR="$PROJECT_ROOT/configs" FRONTEND_DIR="$PROJECT_ROOT/web2-client" if [[ ! -d "$CONFIGS_DIR" ]]; then echo "Expected configs directory at $CONFIGS_DIR." >&2 exit 1 fi if [[ ! -f "$CONFIGS_DIR/docker-compose.yml" ]]; then echo "Missing docker-compose.yml in $CONFIGS_DIR." >&2 exit 1 fi if [[ ! -d "$FRONTEND_DIR" ]]; then echo "Warning: web2-client directory not found at $FRONTEND_DIR (frontend build will fail)." >&2 fi ENV_FILE="$CONFIGS_DIR/.env" ENV_EXAMPLE="$BACKEND_ROOT/env.example" trim() { local val="$1" val="${val#${val%%[![:space:]]*}}" val="${val%${val##*[![:space:]]}}" printf '%s' "$val" } ini_val() { local key="$1" if [[ -f "$ENV_FILE" ]]; then awk -F'=' -v k="$key" 'BEGIN{found=0} $1==k{print substr($0,index($0,$2)); found=1; exit} END{if(!found){} }' "$ENV_FILE" fi } update_env() { local key="$1" local value="$2" if [[ -f "$ENV_FILE" ]]; then if grep -qE "^${key}=" "$ENV_FILE"; then sed -i "s|^${key}=.*$|${key}=${value}|" "$ENV_FILE" return fi fi echo "${key}=${value}" >> "$ENV_FILE" } prompt_required() { local var="$1" local label="$2" local default_val="${3:-}" local value while true; do if [[ -n "$default_val" ]]; then read -r -p "$label [$default_val]: " value || true value=${value:-$default_val} else read -r -p "$label: " value || true fi value=$(trim "$value") if [[ -n "$value" ]]; then printf -v "$var" '%s' "$value" return fi echo "Value is required." done } prompt_optional() { local var="$1" local label="$2" local default_val="${3:-}" local value if [[ -n "$default_val" ]]; then read -r -p "$label [$default_val]: " value || true value=${value:-$default_val} else read -r -p "$label: " value || true fi value=$(trim "$value") printf -v "$var" '%s' "$value" } # Prepare environment file if [[ ! -f "$ENV_FILE" ]]; then echo "Creating $ENV_FILE from example template." >&2 if [[ -f "$ENV_EXAMPLE" ]]; then cp "$ENV_EXAMPLE" "$ENV_FILE" else cp "$CONFIGS_DIR/.env.example" "$ENV_FILE" 2>/dev/null || true if [[ ! -f "$ENV_FILE" ]]; then touch "$ENV_FILE" fi fi fi # Install base dependencies apt-get update -y apt-get install -y ca-certificates curl gnupg apt-transport-https software-properties-common git make nginx certbot python3-certbot-nginx python3 jq openssl # Docker repository setup install -m 0755 -d /etc/apt/keyrings if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg fi cat </etc/apt/sources.list.d/docker.list deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $UBUNTU_CODENAME stable EOF_REPO apt-get update -y apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin systemctl enable --now docker if [[ -n "${SUDO_USER:-}" ]]; then usermod -aG docker "$SUDO_USER" fi # Collect interactive inputs EXISTING_DOMAIN=$(ini_val PUBLIC_HOST) EXISTING_DOMAIN=${EXISTING_DOMAIN#https://} EXISTING_DOMAIN=${EXISTING_DOMAIN#http://} DEFAULT_DOMAIN=$(trim "$EXISTING_DOMAIN") prompt_required DOMAIN "Public domain (e.g. node.example.com)" "$DEFAULT_DOMAIN" DOMAIN=$(trim "$DOMAIN") PUBLIC_HOST="https://$DOMAIN" prompt_required EMAIL "Email for Let's Encrypt notifications" "$(ini_val CERTBOT_EMAIL)" DEFAULT_SEEDS=$(trim "${DEFAULT_DOMAIN:+https://$DEFAULT_DOMAIN}") if [[ -z "$DEFAULT_SEEDS" ]]; then DEFAULT_SEEDS="https://my-public-node-8.projscale.dev" fi prompt_required BOOTSTRAP_SEEDS "Bootstrap seed URLs (comma-separated)" "$DEFAULT_SEEDS" IFS=',' read -r -a _seed_array <<< "$BOOTSTRAP_SEEDS" BOOTSTRAP_SEEDS="" for entry in "${_seed_array[@]}"; do entry=$(trim "$entry") [[ -z "$entry" ]] && continue if [[ $entry != http://* && $entry != https://* ]]; then entry="https://$entry" fi if [[ -z "$BOOTSTRAP_SEEDS" ]]; then BOOTSTRAP_SEEDS="$entry" else BOOTSTRAP_SEEDS="$BOOTSTRAP_SEEDS,$entry" fi done if [[ -z "$BOOTSTRAP_SEEDS" ]]; then echo 'At least one bootstrap seed is required.' >&2 exit 1 fi prompt_optional NODE_PRIVACY "Node privacy (public/private)" "$(ini_val NODE_PRIVACY)" if [[ -z "$NODE_PRIVACY" ]]; then NODE_PRIVACY="public" fi NODE_PRIVACY=$(echo "$NODE_PRIVACY" | tr '[:upper:]' '[:lower:]') if [[ "$NODE_PRIVACY" != "public" && "$NODE_PRIVACY" != "private" ]]; then echo "Invalid privacy option, defaulting to public." >&2 NODE_PRIVACY="public" fi DEFAULT_SANIC=$(ini_val SANIC_PORT) DEFAULT_SANIC=${DEFAULT_SANIC:-13200} prompt_optional SANIC_PORT "Internal backend port" "$DEFAULT_SANIC" DEFAULT_BACKEND_PORT=$(ini_val BACKEND_HTTP_PORT) DEFAULT_BACKEND_PORT=${DEFAULT_BACKEND_PORT:-13200} prompt_optional BACKEND_HTTP_PORT "Public backend port" "$DEFAULT_BACKEND_PORT" DEFAULT_FRONTEND_PORT=$(ini_val FRONTEND_HTTP_PORT) DEFAULT_FRONTEND_PORT=${DEFAULT_FRONTEND_PORT:-13300} prompt_optional FRONTEND_HTTP_PORT "Public frontend port" "$DEFAULT_FRONTEND_PORT" DEFAULT_TUSD_PORT=$(ini_val TUSD_HTTP_PORT) DEFAULT_TUSD_PORT=${DEFAULT_TUSD_PORT:-13400} prompt_optional TUSD_HTTP_PORT "Public tusd port" "$DEFAULT_TUSD_PORT" DEFAULT_PG_PORT=$(ini_val POSTGRES_FORWARD_PORT) DEFAULT_PG_PORT=${DEFAULT_PG_PORT:-13580} prompt_optional POSTGRES_FORWARD_PORT "Public Postgres port" "$DEFAULT_PG_PORT" prompt_required TELEGRAM_API_KEY "Telegram uploader bot token" "$(ini_val TELEGRAM_API_KEY)" prompt_required CLIENT_TELEGRAM_API_KEY "Telegram client bot token" "$(ini_val CLIENT_TELEGRAM_API_KEY)" prompt_optional ADMIN_API_TOKEN "Admin API token" "$(ini_val ADMIN_API_TOKEN)" if [[ -z "$ADMIN_API_TOKEN" ]]; then ADMIN_API_TOKEN=$(openssl rand -hex 16) echo "Generated ADMIN_API_TOKEN=$ADMIN_API_TOKEN" fi prompt_required TONCENTER_API_KEY "TON Center API key" "$(ini_val TONCENTER_API_KEY)" prompt_optional TON_INIT_HOT_SEED "TON hot wallet seed hex (leave blank to auto-generate)" "$(ini_val TON_INIT_HOT_SEED)" prompt_required CONTENT_KEY_KEK_B64 "CONTENT_KEY_KEK_B64 (32-byte base64)" "$(ini_val CONTENT_KEY_KEK_B64)" if ! python3 - "$CONTENT_KEY_KEK_B64" <<'PY'; then import base64, sys val = sys.argv[1] raw = base64.b64decode(val + '===' , validate=False) if len(raw) != 32: raise SystemExit(1) PY echo 'Invalid CONTENT_KEY_KEK_B64: must be base64-encoded 32 bytes.' >&2 exit 1 fi prompt_required SWARM_KEY_HEX "IPFS swarm secret (32-byte hex)" "" SWARM_KEY_HEX=$(echo "$SWARM_KEY_HEX" | tr '[:lower:]' '[:upper:]') if ! python3 - "$SWARM_KEY_HEX" <<'PY'; then import sys val = sys.argv[1].strip() bytes.fromhex(val) if len(val) != 64: raise SystemExit(1) PY echo 'Invalid IPFS swarm key: must be 64 hex chars (32 bytes).' >&2 exit 1 fi update_env CERTBOT_EMAIL "$EMAIL" update_env PUBLIC_HOST "$PUBLIC_HOST" update_env PROJECT_HOST "$PUBLIC_HOST" update_env NODE_PRIVACY "$NODE_PRIVACY" update_env BOOTSTRAP_SEEDS "$BOOTSTRAP_SEEDS" update_env SANIC_PORT "$SANIC_PORT" update_env BACKEND_HTTP_PORT "$BACKEND_HTTP_PORT" update_env FRONTEND_HTTP_PORT "$FRONTEND_HTTP_PORT" update_env TUSD_HTTP_PORT "$TUSD_HTTP_PORT" update_env POSTGRES_FORWARD_PORT "$POSTGRES_FORWARD_PORT" update_env TELEGRAM_API_KEY "$TELEGRAM_API_KEY" update_env CLIENT_TELEGRAM_API_KEY "$CLIENT_TELEGRAM_API_KEY" update_env ADMIN_API_TOKEN "$ADMIN_API_TOKEN" update_env TONCENTER_API_KEY "$TONCENTER_API_KEY" if [[ -n "$TON_INIT_HOT_SEED" ]]; then update_env TON_INIT_HOT_SEED "$TON_INIT_HOT_SEED" fi update_env CONTENT_KEY_KEK_B64 "$CONTENT_KEY_KEK_B64" update_env HANDSHAKE_INTERVAL_SEC "60" update_env BOOTSTRAP_REQUIRED "1" update_env VITE_API_BASE_URL "${PUBLIC_HOST}/api/v1" update_env VITE_API_BASE_STORAGE_URL "${PUBLIC_HOST}/api/v1.5/storage" update_env VITE_TUS_ENDPOINT "${PUBLIC_HOST}/tus/files" update_env TON_CONNECT_MANIFEST_URI "${PUBLIC_HOST}/api/tonconnect-manifest.json" # Ensure swarm key file mkdir -p "$CONFIGS_DIR/ipfs" cat <"$CONFIGS_DIR/ipfs/swarm.key" /key/swarm/psk/1.0.0/ /base16/ $SWARM_KEY_HEX EOF_SWARM chmod 600 "$CONFIGS_DIR/ipfs/swarm.key" update_env IPFS_SWARM_KEY_FILE "$CONFIGS_DIR/ipfs/swarm.key" # Issue TLS certificate if [[ ! -d "/etc/letsencrypt/live/$DOMAIN" ]]; then systemctl stop nginx || true certbot certonly --standalone --agree-tos --non-interactive -m "$EMAIL" -d "$DOMAIN" systemctl start nginx fi systemctl enable --now nginx # Nginx configuration cat </etc/nginx/sites-available/my-network.conf upstream backend_app { server 127.0.0.1:$SANIC_PORT; keepalive 32; } upstream frontend_web { server 127.0.0.1:$FRONTEND_HTTP_PORT; keepalive 16; } upstream tusd_backend { server 127.0.0.1:$TUSD_HTTP_PORT; keepalive 16; } server { listen 80; server_name $DOMAIN; return 301 https://$DOMAIN$request_uri; } server { listen 443 ssl http2; server_name $DOMAIN; ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; client_max_body_size 10G; proxy_read_timeout 300s; location / { proxy_pass http://frontend_web; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /assets/ { proxy_pass http://frontend_web; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header Cache-Control; add_header Cache-Control "public, max-age=31536000, immutable" always; } location /tus/ { proxy_pass http://tusd_backend/; proxy_request_buffering off; proxy_buffering off; proxy_max_temp_file_size 0; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /files/ { proxy_pass http://tusd_backend; proxy_request_buffering off; proxy_buffering off; proxy_max_temp_file_size 0; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /api/ { proxy_pass http://backend_app; proxy_http_version 1.1; proxy_request_buffering off; proxy_buffering off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location = /health { proxy_pass http://backend_app/api/system.version; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } EOF_NGX ln -sf /etc/nginx/sites-available/my-network.conf /etc/nginx/sites-enabled/my-network.conf rm -f /etc/nginx/sites-enabled/default nginx -t systemctl reload nginx # Bootstrap docker-compose stack make -C "$CONFIGS_DIR" bootstrap echo "\nNode provisioning complete." echo "- Admin panel: ${PUBLIC_HOST}/admin (use ADMIN_API_TOKEN)" echo "- To trust this node on peers, mark it via admin API on existing node." echo "- Docker services: run 'docker ps -a' or 'make -C $CONFIGS_DIR ps'."