454 lines
14 KiB
Bash
Executable File
454 lines
14 KiB
Bash
Executable File
#!/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)
|
|
if [[ -d "$SCRIPT_DIR/../app" && -d "$SCRIPT_DIR/../scripts" ]]; then
|
|
DEFAULT_INSTALL_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
|
|
else
|
|
DEFAULT_INSTALL_ROOT="/home/my-network"
|
|
fi
|
|
|
|
read -r -p "Installation root [$DEFAULT_INSTALL_ROOT]: " INSTALL_ROOT || true
|
|
INSTALL_ROOT=${INSTALL_ROOT:-$DEFAULT_INSTALL_ROOT}
|
|
INSTALL_ROOT=${INSTALL_ROOT%/}
|
|
if [[ -z "$INSTALL_ROOT" ]]; then
|
|
INSTALL_ROOT="/home/my-network"
|
|
fi
|
|
mkdir -p "$INSTALL_ROOT"
|
|
INSTALL_ROOT=$(cd "$INSTALL_ROOT" && pwd)
|
|
|
|
echo "Using installation root: $INSTALL_ROOT"
|
|
|
|
PROJECT_ROOT="$INSTALL_ROOT"
|
|
BACKEND_ROOT="$PROJECT_ROOT/uploader-bot"
|
|
CONFIGS_DIR="$PROJECT_ROOT/configs"
|
|
FRONTEND_DIR="$PROJECT_ROOT/web2-client"
|
|
|
|
ENV_FILE="$CONFIGS_DIR/.env"
|
|
ENV_EXAMPLE="$BACKEND_ROOT/env.example"
|
|
|
|
DEFAULT_BACKEND_REMOTE="https://git.projscale.dev/my-dev/uploader-bot"
|
|
if [[ -d "$BACKEND_ROOT/.git" ]]; then
|
|
DEFAULT_BACKEND_REMOTE=$(git -C "$BACKEND_ROOT" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_BACKEND_REMOTE")
|
|
fi
|
|
DEFAULT_CONFIGS_REMOTE="https://git.projscale.dev/my-dev/configs"
|
|
if [[ -d "$CONFIGS_DIR/.git" ]]; then
|
|
DEFAULT_CONFIGS_REMOTE=$(git -C "$CONFIGS_DIR" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_CONFIGS_REMOTE")
|
|
fi
|
|
DEFAULT_WEB2_REMOTE="https://git.projscale.dev/my-dev/web2-client"
|
|
if [[ -d "$FRONTEND_DIR/.git" ]]; then
|
|
DEFAULT_WEB2_REMOTE=$(git -C "$FRONTEND_DIR" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_WEB2_REMOTE")
|
|
fi
|
|
|
|
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"
|
|
}
|
|
|
|
RUN_USER=${SUDO_USER:-$(logname 2>/dev/null || echo root)}
|
|
if ! id -u "$RUN_USER" >/dev/null 2>&1; then
|
|
RUN_USER=root
|
|
fi
|
|
RUN_GROUP=$(id -gn "$RUN_USER" 2>/dev/null || echo $RUN_USER)
|
|
|
|
ensure_repo() {
|
|
local dir="$1"
|
|
local default_url="$2"
|
|
local label="$3"
|
|
|
|
if [[ -d "$dir/.git" ]]; then
|
|
return
|
|
fi
|
|
if [[ -d "$dir" && ! -d "$dir/.git" ]]; then
|
|
echo "Found existing $label directory at $dir without git metadata." >&2
|
|
read -r -p "Use the existing directory as-is? [y/N]: " reuse || true
|
|
reuse=${reuse:-N}
|
|
if [[ ! $reuse =~ ^[Yy]$ ]]; then
|
|
echo "Please remove or move $dir and rerun the script." >&2
|
|
exit 1
|
|
fi
|
|
return
|
|
fi
|
|
|
|
read -r -p "Git URL for $label repository [$default_url]: " repo_url || true
|
|
repo_url=${repo_url:-$default_url}
|
|
mkdir -p "$(dirname "$dir")"
|
|
if ! git clone "$repo_url" "$dir"; then
|
|
echo "Failed to clone $label repository from $repo_url" >&2
|
|
exit 1
|
|
fi
|
|
chown -R "$RUN_USER:$RUN_GROUP" "$dir" || true
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
# 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
|
|
|
|
# Ensure repository checkouts
|
|
ensure_repo "$BACKEND_ROOT" "$DEFAULT_BACKEND_REMOTE" "uploader-bot"
|
|
ensure_repo "$CONFIGS_DIR" "$DEFAULT_CONFIGS_REMOTE" "configs"
|
|
ensure_repo "$FRONTEND_DIR" "$DEFAULT_WEB2_REMOTE" "web2-client"
|
|
|
|
# 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
|
|
chown "$RUN_USER:$RUN_GROUP" "$ENV_FILE" || true
|
|
|
|
# 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 | gpg --dearmor | tee /etc/apt/keyrings/docker.gpg >/dev/null
|
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
|
fi
|
|
cat <<EOF_REPO >/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 <<EOF_SWARM >"$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 <<EOF_NGX >/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'."
|
|
echo "- Installation root: $PROJECT_ROOT"
|