1234 lines
39 KiB
Bash
1234 lines
39 KiB
Bash
#!/bin/sh
|
|
set -u
|
|
|
|
# Interactive installer for fresh Docker Compose hosts.
|
|
|
|
DEFAULT_REPO="${MINISHOP_INSTALL_REPO:-3252a8/remnawave-minishop}"
|
|
DEFAULT_REF="${MINISHOP_INSTALL_REF:-main}"
|
|
DEFAULT_IMAGE_TAG="${MINISHOP_IMAGE_TAG:-latest}"
|
|
INSTALL_STATE_DIR=".installer"
|
|
IMPORTER_CACHE_PATH="$INSTALL_STATE_DIR/import_legacy.py"
|
|
APP_UID=10001
|
|
APP_GID=10001
|
|
OLD_TGSHOP_DB_VOLUME="remnawave-tg-shop-db-data"
|
|
NEW_MINISHOP_DB_VOLUME="remnawave-minishop-db-data"
|
|
OLD_TGSHOP_CADDY_DATA_VOLUME="remnawave-tg-shop-caddy-data"
|
|
OLD_TGSHOP_CADDY_CONFIG_VOLUME="remnawave-tg-shop-caddy-config"
|
|
NEW_MINISHOP_CADDY_DATA_VOLUME="remnawave-minishop-caddy-data"
|
|
NEW_MINISHOP_CADDY_CONFIG_VOLUME="remnawave-minishop-caddy-config"
|
|
KNOWN_LEGACY_CONTAINERS="remnawave-tg-shop remnawave-tg-shop-db remnawave-tg-shop-caddy remnawave-minishop remnawave-minishop-db remnawave-minishop-caddy remnawave-minishop-backend remnawave-minishop-worker remnawave-minishop-frontend remnawave-minishop-migrate remnawave-minishop-postgres remnawave-minishop-redis"
|
|
|
|
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
|
RESET="$(printf '\033[0m')"
|
|
BOLD="$(printf '\033[1m')"
|
|
DIM="$(printf '\033[2m')"
|
|
RED="$(printf '\033[31m')"
|
|
GREEN="$(printf '\033[32m')"
|
|
YELLOW="$(printf '\033[33m')"
|
|
BLUE="$(printf '\033[34m')"
|
|
CYAN="$(printf '\033[36m')"
|
|
else
|
|
RESET=""
|
|
BOLD=""
|
|
DIM=""
|
|
RED=""
|
|
GREEN=""
|
|
YELLOW=""
|
|
BLUE=""
|
|
CYAN=""
|
|
fi
|
|
|
|
TARGET_DIR=""
|
|
SOURCE_REPO=""
|
|
SOURCE_REF=""
|
|
PROFILE_KEY=""
|
|
ENV_PATH=""
|
|
COMPOSE_STYLE=""
|
|
PROMPT_VALUE=""
|
|
CHOICE_VALUE=""
|
|
LEGACY_SOURCE=""
|
|
SOURCE_ENV_PATH=""
|
|
|
|
COMPOSE_PROJECT_NAME_VALUE=""
|
|
IMAGE_TAG_VALUE=""
|
|
WEBHOOK_HOST_VALUE=""
|
|
MINIAPP_HOST_VALUE=""
|
|
WEBHOOK_PUBLIC_URL_VALUE=""
|
|
MINIAPP_PUBLIC_URL_VALUE=""
|
|
HTTP_BIND_VALUE=""
|
|
HTTPS_BIND_VALUE=""
|
|
WEB_SERVER_BIND_VALUE=""
|
|
FRONTEND_BIND_VALUE=""
|
|
PANGOLIN_ENDPOINT_VALUE=""
|
|
NEWT_ID_VALUE=""
|
|
NEWT_SECRET_VALUE=""
|
|
BOT_TOKEN_VALUE=""
|
|
ADMIN_IDS_VALUE=""
|
|
POSTGRES_USER_VALUE=""
|
|
POSTGRES_PASSWORD_VALUE=""
|
|
POSTGRES_DB_VALUE=""
|
|
WEBAPP_ENABLED_VALUE=""
|
|
WEBAPP_SESSION_SECRET_VALUE=""
|
|
WEBHOOK_SECRET_TOKEN_VALUE=""
|
|
TRUSTED_PROXIES_VALUE=""
|
|
PANEL_API_URL_VALUE=""
|
|
PANEL_API_KEY_VALUE=""
|
|
PANEL_WEBHOOK_SECRET_VALUE=""
|
|
|
|
KNOWN_ENV_KEYS="COMPOSE_PROJECT_NAME IMAGE_TAG WEBHOOK_HOST MINIAPP_HOST WEBHOOK_PUBLIC_URL MINIAPP_PUBLIC_URL HTTP_BIND HTTPS_BIND WEB_SERVER_BIND FRONTEND_BIND PANGOLIN_ENDPOINT NEWT_ID NEWT_SECRET BOT_TOKEN ADMIN_IDS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB WEBAPP_ENABLED WEBAPP_SESSION_SECRET WEBHOOK_SECRET_TOKEN TRUSTED_PROXIES PANEL_API_URL PANEL_API_KEY PANEL_WEBHOOK_SECRET"
|
|
|
|
color() {
|
|
printf '%s%s%s' "$2" "$1" "$RESET"
|
|
}
|
|
|
|
banner() {
|
|
printf '\n'
|
|
color "Remnawave MiniShop Install Wizard" "$BOLD$CYAN"
|
|
printf '\n'
|
|
color "Install, configure, start, and migrate existing bot data." "$DIM"
|
|
printf '\n\n'
|
|
}
|
|
|
|
section() {
|
|
printf '\n'
|
|
color "== $1 ==" "$BOLD$BLUE"
|
|
printf '\n'
|
|
}
|
|
|
|
info() {
|
|
color "* " "$CYAN"
|
|
printf '%s\n' "$1"
|
|
}
|
|
|
|
warn() {
|
|
color "! " "$YELLOW"
|
|
printf '%s\n' "$1"
|
|
}
|
|
|
|
ok() {
|
|
color "[ok] " "$GREEN"
|
|
printf '%s\n' "$1"
|
|
}
|
|
|
|
fail() {
|
|
color "[x] " "$RED"
|
|
printf '%s\n' "$1" >&2
|
|
}
|
|
|
|
pause() {
|
|
printf '%s' "${DIM}Press Enter to continue...${RESET}"
|
|
# shellcheck disable=SC2034
|
|
read -r _
|
|
}
|
|
|
|
print_help() {
|
|
cat <<EOF
|
|
Environment overrides:
|
|
MINISHOP_INSTALL_DIR default install directory
|
|
MINISHOP_INSTALL_REPO default repository ($DEFAULT_REPO)
|
|
MINISHOP_INSTALL_REF default ref ($DEFAULT_REF)
|
|
MINISHOP_IMAGE_TAG default image tag ($DEFAULT_IMAGE_TAG)
|
|
REMNASHOP_SOURCE_DSN default source DSN for migration
|
|
REMNASHOP_SOURCE_ENV_FILE default source Remnashop .env path for migration
|
|
LEGACY_TGSHOP_SOURCE_DSN default remnawave-tg-shop source DSN for dump/restore
|
|
|
|
The wizard is interactive by design. It never overwrites files without
|
|
confirmation. Remnashop imports always run dry-run first; remnawave-tg-shop
|
|
can be migrated from Docker volumes or a PostgreSQL DSN.
|
|
EOF
|
|
}
|
|
|
|
mask_secret() {
|
|
value="${1:-}"
|
|
length=${#value}
|
|
if [ "$length" -eq 0 ]; then
|
|
printf ''
|
|
elif [ "$length" -le 8 ]; then
|
|
printf '****'
|
|
else
|
|
first=$(printf '%s' "$value" | cut -c 1-3)
|
|
last=$(printf '%s' "$value" | awk '{ print substr($0, length($0)-2) }')
|
|
printf '%s...%s' "$first" "$last"
|
|
fi
|
|
}
|
|
|
|
is_secret_key() {
|
|
case "$1" in
|
|
BOT_TOKEN|POSTGRES_PASSWORD|WEBAPP_SESSION_SECRET|WEBHOOK_SECRET_TOKEN|PANEL_API_KEY|PANEL_WEBHOOK_SECRET|NEWT_SECRET)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
validate_value() {
|
|
value="$1"
|
|
validator="$2"
|
|
case "$validator" in
|
|
"")
|
|
return 0
|
|
;;
|
|
hostname)
|
|
printf '%s' "$value" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9.-]{0,251}[A-Za-z0-9]$'
|
|
;;
|
|
url)
|
|
printf '%s' "$value" | grep -Eq '^https?://[^[:space:]]+$'
|
|
;;
|
|
*)
|
|
return 0
|
|
;;
|
|
esac
|
|
}
|
|
|
|
prompt_value() {
|
|
label="$1"
|
|
default_value="${2:-}"
|
|
required="${3:-0}"
|
|
secret="${4:-0}"
|
|
validator="${5:-}"
|
|
while :; do
|
|
if [ -n "$default_value" ]; then
|
|
if [ "$secret" = "1" ]; then
|
|
shown=$(mask_secret "$default_value")
|
|
else
|
|
shown="$default_value"
|
|
fi
|
|
printf '%s [%s]: ' "$label" "$shown"
|
|
else
|
|
printf '%s: ' "$label"
|
|
fi
|
|
read -r raw_value
|
|
if [ -n "$raw_value" ]; then
|
|
value="$raw_value"
|
|
else
|
|
value="$default_value"
|
|
fi
|
|
if [ "$required" = "1" ] && [ -z "$value" ]; then
|
|
warn "Value is required."
|
|
continue
|
|
fi
|
|
if [ -n "$value" ] && ! validate_value "$value" "$validator"; then
|
|
warn "Value does not look valid."
|
|
continue
|
|
fi
|
|
PROMPT_VALUE="$value"
|
|
return 0
|
|
done
|
|
}
|
|
|
|
confirm() {
|
|
label="$1"
|
|
default="${2:-0}"
|
|
if [ "$default" = "1" ]; then
|
|
suffix="Y/n"
|
|
else
|
|
suffix="y/N"
|
|
fi
|
|
while :; do
|
|
printf '%s [%s]: ' "$label" "$suffix"
|
|
read -r answer
|
|
answer=$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')
|
|
if [ -z "$answer" ]; then
|
|
[ "$default" = "1" ]
|
|
return $?
|
|
fi
|
|
case "$answer" in
|
|
y|yes)
|
|
return 0
|
|
;;
|
|
n|no)
|
|
return 1
|
|
;;
|
|
*)
|
|
warn "Answer y or n."
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
choose() {
|
|
title="$1"
|
|
default="$2"
|
|
valid="$3"
|
|
shift 3
|
|
section "$title"
|
|
for label in "$@"; do
|
|
printf ' %s\n' "$label"
|
|
done
|
|
while :; do
|
|
printf 'Choose [%s]: ' "$default"
|
|
read -r selected
|
|
selected="${selected:-$default}"
|
|
case "|$valid|" in
|
|
*"|$selected|"*)
|
|
CHOICE_VALUE="$selected"
|
|
return 0
|
|
;;
|
|
*)
|
|
warn "Unknown menu item."
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
strip_quotes() {
|
|
value="$1"
|
|
case "$value" in
|
|
\"*\")
|
|
value=${value#\"}
|
|
value=${value%\"}
|
|
;;
|
|
\'*\')
|
|
value=${value#\'}
|
|
value=${value%\'}
|
|
;;
|
|
esac
|
|
printf '%s' "$value"
|
|
}
|
|
|
|
env_get() {
|
|
key="$1"
|
|
default_value="${2:-}"
|
|
if [ -f "$ENV_PATH" ]; then
|
|
line=$(grep -E "^${key}=" "$ENV_PATH" 2>/dev/null | tail -n 1 || true)
|
|
if [ -n "$line" ]; then
|
|
strip_quotes "${line#*=}"
|
|
return 0
|
|
fi
|
|
fi
|
|
printf '%s' "$default_value"
|
|
}
|
|
|
|
known_env_key() {
|
|
case " $KNOWN_ENV_KEYS " in
|
|
*" $1 "*)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
secret_hex() {
|
|
bytes="${1:-32}"
|
|
if command -v openssl >/dev/null 2>&1; then
|
|
openssl rand -hex "$bytes"
|
|
return 0
|
|
fi
|
|
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
|
|
dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \n'
|
|
return 0
|
|
fi
|
|
fail "Could not generate a secure secret. Install openssl and retry."
|
|
exit 1
|
|
}
|
|
|
|
generated_password() {
|
|
secret_hex 24
|
|
}
|
|
|
|
raw_url() {
|
|
repo=$(printf '%s' "$1" | sed 's#^/*##; s#/*$##')
|
|
ref=$(printf '%s' "$2" | sed 's#^/*##; s#/*$##')
|
|
path=$(printf '%s' "$3" | sed 's#^/*##')
|
|
printf 'https://raw.githubusercontent.com/%s/%s/%s' "$repo" "$ref" "$path"
|
|
}
|
|
|
|
download_to() {
|
|
url="$1"
|
|
target="$2"
|
|
if command -v curl >/dev/null 2>&1; then
|
|
curl -fsSL "$url" -o "$target"
|
|
return $?
|
|
fi
|
|
if command -v wget >/dev/null 2>&1; then
|
|
wget -qO "$target" "$url"
|
|
return $?
|
|
fi
|
|
fail "curl or wget is required to download files."
|
|
exit 1
|
|
}
|
|
|
|
backup_path() {
|
|
path="$1"
|
|
stamp=$(date -u '+%Y%m%d-%H%M%S')
|
|
printf '%s.bak-%s' "$path" "$stamp"
|
|
}
|
|
|
|
write_downloaded_file() {
|
|
source_path="$1"
|
|
target_path="$2"
|
|
mkdir -p "$(dirname "$target_path")"
|
|
if [ -e "$target_path" ]; then
|
|
if confirm "$target_path exists. Overwrite with backup?" 0; then
|
|
backup=$(backup_path "$target_path")
|
|
cp "$target_path" "$backup"
|
|
info "Backed up $target_path to $(basename "$backup")"
|
|
else
|
|
warn "Keeping existing $target_path"
|
|
rm -f "$source_path"
|
|
return 0
|
|
fi
|
|
fi
|
|
mv "$source_path" "$target_path"
|
|
ok "Wrote $target_path"
|
|
}
|
|
|
|
download_raw_file() {
|
|
source="$1"
|
|
target="$2"
|
|
required="${3:-1}"
|
|
url=$(raw_url "$SOURCE_REPO" "$SOURCE_REF" "$source")
|
|
tmp="$TARGET_DIR/.download.$$.$(basename "$target")"
|
|
if download_to "$url" "$tmp"; then
|
|
write_downloaded_file "$tmp" "$TARGET_DIR/$target"
|
|
return 0
|
|
fi
|
|
rm -f "$tmp"
|
|
if [ "$required" = "1" ]; then
|
|
fail "Could not download $url"
|
|
return 1
|
|
fi
|
|
warn "Skipping optional file $source"
|
|
return 0
|
|
}
|
|
|
|
choose_profile() {
|
|
choose "Deployment profile" "1" "1|2|3|4" \
|
|
"1. Caddy HTTPS - recommended public HTTPS with automatic certificates." \
|
|
"2. Nginx HTTPS - TLS certificates are managed manually." \
|
|
"3. Pangolin / Newt - no inbound ports; public routes are configured in Pangolin." \
|
|
"4. No proxy / external TLS - direct HTTP ports or an external TLS terminator."
|
|
case "$CHOICE_VALUE" in
|
|
1) PROFILE_KEY="caddy" ;;
|
|
2) PROFILE_KEY="nginx" ;;
|
|
3) PROFILE_KEY="newt" ;;
|
|
4) PROFILE_KEY="no-proxy" ;;
|
|
esac
|
|
}
|
|
|
|
download_profile_files() {
|
|
section "Download deployment files"
|
|
case "$PROFILE_KEY" in
|
|
caddy)
|
|
download_raw_file "deploy/examples/caddy/docker-compose.yml" "docker-compose.yml" 1 || return 1
|
|
download_raw_file "deploy/examples/caddy/Caddyfile" "Caddyfile" 1 || return 1
|
|
download_raw_file "deploy/examples/caddy/.env.example" ".env.example" 1 || return 1
|
|
;;
|
|
nginx)
|
|
download_raw_file "deploy/examples/nginx/docker-compose.yml" "docker-compose.yml" 1 || return 1
|
|
download_raw_file "deploy/examples/nginx/nginx.conf.template" "nginx.conf.template" 1 || return 1
|
|
download_raw_file "deploy/examples/nginx/.env.example" ".env.example" 1 || return 1
|
|
download_raw_file "deploy/examples/nginx/ssl/README.md" "ssl/README.md" 1 || return 1
|
|
;;
|
|
newt)
|
|
download_raw_file "deploy/examples/newt/docker-compose.yml" "docker-compose.yml" 1 || return 1
|
|
download_raw_file "deploy/examples/newt/.env.example" ".env.example" 1 || return 1
|
|
;;
|
|
no-proxy)
|
|
download_raw_file "deploy/examples/no-proxy/docker-compose.yml" "docker-compose.yml" 1 || return 1
|
|
download_raw_file "deploy/examples/no-proxy/.env.example" ".env.example" 1 || return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
prompt_common_env() {
|
|
section "Minimal .env"
|
|
prompt_value "Compose project name" "$(env_get COMPOSE_PROJECT_NAME remnawave-minishop)" 0 0 ""
|
|
COMPOSE_PROJECT_NAME_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Image tag" "$(env_get IMAGE_TAG "$DEFAULT_IMAGE_TAG")" 0 0 ""
|
|
IMAGE_TAG_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Telegram bot token" "$(env_get BOT_TOKEN '')" 1 1 ""
|
|
BOT_TOKEN_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Admin Telegram IDs, comma-separated" "$(env_get ADMIN_IDS '')" 1 0 ""
|
|
ADMIN_IDS_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Postgres user" "$(env_get POSTGRES_USER remnawave_minishop)" 1 0 ""
|
|
POSTGRES_USER_VALUE="$PROMPT_VALUE"
|
|
existing_postgres_password=$(env_get POSTGRES_PASSWORD "")
|
|
if [ -z "$existing_postgres_password" ]; then
|
|
existing_postgres_password=$(generated_password)
|
|
fi
|
|
prompt_value "Postgres password" "$existing_postgres_password" 1 1 ""
|
|
POSTGRES_PASSWORD_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Postgres database" "$(env_get POSTGRES_DB remnawave_minishop)" 1 0 ""
|
|
POSTGRES_DB_VALUE="$PROMPT_VALUE"
|
|
|
|
WEBAPP_ENABLED_VALUE="$(env_get WEBAPP_ENABLED True)"
|
|
WEBAPP_SESSION_SECRET_VALUE="$(env_get WEBAPP_SESSION_SECRET "")"
|
|
if [ -z "$WEBAPP_SESSION_SECRET_VALUE" ]; then
|
|
WEBAPP_SESSION_SECRET_VALUE="$(secret_hex 32)"
|
|
fi
|
|
WEBHOOK_SECRET_TOKEN_VALUE="$(env_get WEBHOOK_SECRET_TOKEN "")"
|
|
if [ -z "$WEBHOOK_SECRET_TOKEN_VALUE" ]; then
|
|
WEBHOOK_SECRET_TOKEN_VALUE="$(secret_hex 32)"
|
|
fi
|
|
|
|
prompt_value "Remnawave Panel API URL" "$(env_get PANEL_API_URL https://panel.example.com/api)" 0 0 "url"
|
|
PANEL_API_URL_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Remnawave Panel API key" "$(env_get PANEL_API_KEY change_me)" 0 1 ""
|
|
PANEL_API_KEY_VALUE="$PROMPT_VALUE"
|
|
existing_panel_webhook_secret=$(env_get PANEL_WEBHOOK_SECRET "")
|
|
if [ -z "$existing_panel_webhook_secret" ]; then
|
|
existing_panel_webhook_secret=$(secret_hex 24)
|
|
fi
|
|
prompt_value "Remnawave Panel webhook secret" "$existing_panel_webhook_secret" 0 1 ""
|
|
PANEL_WEBHOOK_SECRET_VALUE="$PROMPT_VALUE"
|
|
|
|
case "$PROFILE_KEY" in
|
|
caddy|nginx|newt)
|
|
prompt_value "Webhook/API public hostname" "$(env_get WEBHOOK_HOST webhooks.example.com)" 1 0 "hostname"
|
|
WEBHOOK_HOST_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Mini App public hostname" "$(env_get MINIAPP_HOST app.example.com)" 1 0 "hostname"
|
|
MINIAPP_HOST_VALUE="$PROMPT_VALUE"
|
|
TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1,172.16.0.0/12')"
|
|
;;
|
|
esac
|
|
|
|
case "$PROFILE_KEY" in
|
|
caddy|nginx)
|
|
prompt_value "HTTP bind" "$(env_get HTTP_BIND '0.0.0.0:80')" 0 0 ""
|
|
HTTP_BIND_VALUE="$PROMPT_VALUE"
|
|
prompt_value "HTTPS bind" "$(env_get HTTPS_BIND '0.0.0.0:443')" 0 0 ""
|
|
HTTPS_BIND_VALUE="$PROMPT_VALUE"
|
|
;;
|
|
newt)
|
|
prompt_value "Pangolin endpoint" "$(env_get PANGOLIN_ENDPOINT https://pangolin.example.com)" 1 0 "url"
|
|
PANGOLIN_ENDPOINT_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Newt ID" "$(env_get NEWT_ID '')" 1 0 ""
|
|
NEWT_ID_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Newt secret" "$(env_get NEWT_SECRET '')" 1 1 ""
|
|
NEWT_SECRET_VALUE="$PROMPT_VALUE"
|
|
;;
|
|
no-proxy)
|
|
prompt_value "Backend bind" "$(env_get WEB_SERVER_BIND '0.0.0.0:8080')" 0 0 ""
|
|
WEB_SERVER_BIND_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Frontend bind" "$(env_get FRONTEND_BIND '0.0.0.0:8082')" 0 0 ""
|
|
FRONTEND_BIND_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Webhook public URL" "$(env_get WEBHOOK_PUBLIC_URL 'http://127.0.0.1:8080')" 1 0 "url"
|
|
WEBHOOK_PUBLIC_URL_VALUE="$PROMPT_VALUE"
|
|
prompt_value "Mini App public URL" "$(env_get MINIAPP_PUBLIC_URL 'http://127.0.0.1:8082/')" 1 0 "url"
|
|
MINIAPP_PUBLIC_URL_VALUE="$PROMPT_VALUE"
|
|
TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1')"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
env_line() {
|
|
key="$1"
|
|
value="$2"
|
|
file="$3"
|
|
if [ -n "$value" ]; then
|
|
printf '%s=%s\n' "$key" "$value" >> "$file"
|
|
fi
|
|
}
|
|
|
|
show_env_value() {
|
|
key="$1"
|
|
value="$2"
|
|
if [ -z "$value" ]; then
|
|
return 0
|
|
fi
|
|
if is_secret_key "$key"; then
|
|
value=$(mask_secret "$value")
|
|
fi
|
|
printf ' %s=%s\n' "$key" "$value"
|
|
}
|
|
|
|
display_env_summary() {
|
|
show_env_value COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME_VALUE"
|
|
show_env_value IMAGE_TAG "$IMAGE_TAG_VALUE"
|
|
show_env_value WEBHOOK_HOST "$WEBHOOK_HOST_VALUE"
|
|
show_env_value MINIAPP_HOST "$MINIAPP_HOST_VALUE"
|
|
show_env_value WEBHOOK_PUBLIC_URL "$WEBHOOK_PUBLIC_URL_VALUE"
|
|
show_env_value MINIAPP_PUBLIC_URL "$MINIAPP_PUBLIC_URL_VALUE"
|
|
show_env_value BOT_TOKEN "$BOT_TOKEN_VALUE"
|
|
show_env_value ADMIN_IDS "$ADMIN_IDS_VALUE"
|
|
show_env_value POSTGRES_USER "$POSTGRES_USER_VALUE"
|
|
show_env_value POSTGRES_PASSWORD "$POSTGRES_PASSWORD_VALUE"
|
|
show_env_value POSTGRES_DB "$POSTGRES_DB_VALUE"
|
|
show_env_value WEBAPP_SESSION_SECRET "$WEBAPP_SESSION_SECRET_VALUE"
|
|
show_env_value WEBHOOK_SECRET_TOKEN "$WEBHOOK_SECRET_TOKEN_VALUE"
|
|
show_env_value PANEL_API_URL "$PANEL_API_URL_VALUE"
|
|
show_env_value PANEL_API_KEY "$PANEL_API_KEY_VALUE"
|
|
show_env_value PANEL_WEBHOOK_SECRET "$PANEL_WEBHOOK_SECRET_VALUE"
|
|
}
|
|
|
|
append_preserved_env() {
|
|
output="$1"
|
|
[ -f "$ENV_PATH" ] || return 0
|
|
wrote_header=0
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
""|\#*)
|
|
continue
|
|
;;
|
|
*=*)
|
|
key=${line%%=*}
|
|
if known_env_key "$key"; then
|
|
continue
|
|
fi
|
|
if [ "$wrote_header" = "0" ]; then
|
|
printf '\n# Preserved from previous .env\n' >> "$output"
|
|
wrote_header=1
|
|
fi
|
|
printf '%s\n' "$line" >> "$output"
|
|
;;
|
|
esac
|
|
done < "$ENV_PATH"
|
|
}
|
|
|
|
render_env_file() {
|
|
output="$1"
|
|
: > "$output"
|
|
printf '# Deployment\n' >> "$output"
|
|
env_line COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME_VALUE" "$output"
|
|
env_line IMAGE_TAG "$IMAGE_TAG_VALUE" "$output"
|
|
env_line WEBHOOK_HOST "$WEBHOOK_HOST_VALUE" "$output"
|
|
env_line MINIAPP_HOST "$MINIAPP_HOST_VALUE" "$output"
|
|
env_line WEBHOOK_PUBLIC_URL "$WEBHOOK_PUBLIC_URL_VALUE" "$output"
|
|
env_line MINIAPP_PUBLIC_URL "$MINIAPP_PUBLIC_URL_VALUE" "$output"
|
|
env_line HTTP_BIND "$HTTP_BIND_VALUE" "$output"
|
|
env_line HTTPS_BIND "$HTTPS_BIND_VALUE" "$output"
|
|
env_line WEB_SERVER_BIND "$WEB_SERVER_BIND_VALUE" "$output"
|
|
env_line FRONTEND_BIND "$FRONTEND_BIND_VALUE" "$output"
|
|
env_line PANGOLIN_ENDPOINT "$PANGOLIN_ENDPOINT_VALUE" "$output"
|
|
env_line NEWT_ID "$NEWT_ID_VALUE" "$output"
|
|
env_line NEWT_SECRET "$NEWT_SECRET_VALUE" "$output"
|
|
|
|
printf '\n# Telegram\n' >> "$output"
|
|
env_line BOT_TOKEN "$BOT_TOKEN_VALUE" "$output"
|
|
env_line ADMIN_IDS "$ADMIN_IDS_VALUE" "$output"
|
|
|
|
printf '\n# PostgreSQL\n' >> "$output"
|
|
env_line POSTGRES_USER "$POSTGRES_USER_VALUE" "$output"
|
|
env_line POSTGRES_PASSWORD "$POSTGRES_PASSWORD_VALUE" "$output"
|
|
env_line POSTGRES_DB "$POSTGRES_DB_VALUE" "$output"
|
|
|
|
printf '\n# Application\n' >> "$output"
|
|
env_line WEBAPP_ENABLED "$WEBAPP_ENABLED_VALUE" "$output"
|
|
env_line WEBAPP_SESSION_SECRET "$WEBAPP_SESSION_SECRET_VALUE" "$output"
|
|
env_line WEBHOOK_SECRET_TOKEN "$WEBHOOK_SECRET_TOKEN_VALUE" "$output"
|
|
env_line TRUSTED_PROXIES "$TRUSTED_PROXIES_VALUE" "$output"
|
|
|
|
printf '\n# Remnawave Panel\n' >> "$output"
|
|
env_line PANEL_API_URL "$PANEL_API_URL_VALUE" "$output"
|
|
env_line PANEL_API_KEY "$PANEL_API_KEY_VALUE" "$output"
|
|
env_line PANEL_WEBHOOK_SECRET "$PANEL_WEBHOOK_SECRET_VALUE" "$output"
|
|
|
|
append_preserved_env "$output"
|
|
}
|
|
|
|
write_env_file() {
|
|
section "Review .env"
|
|
display_env_summary
|
|
if ! confirm "Write .env now?" 1; then
|
|
warn "Skipped .env write."
|
|
return 0
|
|
fi
|
|
tmp="$TARGET_DIR/.env.tmp.$$"
|
|
render_env_file "$tmp"
|
|
if [ -e "$ENV_PATH" ]; then
|
|
backup=$(backup_path "$ENV_PATH")
|
|
cp "$ENV_PATH" "$backup"
|
|
info "Backed up $ENV_PATH to $(basename "$backup")"
|
|
fi
|
|
mv "$tmp" "$ENV_PATH"
|
|
ok "Wrote $ENV_PATH"
|
|
}
|
|
|
|
prepare_data_mount() {
|
|
section "Prepare data mount"
|
|
data_dir="$TARGET_DIR/data"
|
|
created=0
|
|
if [ ! -d "$data_dir" ]; then
|
|
mkdir -p "$data_dir" || return 1
|
|
created=1
|
|
fi
|
|
|
|
if [ "$created" = "1" ]; then
|
|
if command -v chown >/dev/null 2>&1; then
|
|
if ! chown "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then
|
|
warn "Could not chown $data_dir. Run: sudo chown $APP_UID:$APP_GID data"
|
|
fi
|
|
fi
|
|
chmod u+rwx "$data_dir" 2>/dev/null || true
|
|
ok "Created writable $data_dir"
|
|
return 0
|
|
fi
|
|
|
|
info "$data_dir already exists."
|
|
if confirm "Adjust $data_dir owner to $APP_UID:$APP_GID for container writes?" 0; then
|
|
if command -v chown >/dev/null 2>&1; then
|
|
if ! chown "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then
|
|
warn "Could not chown $data_dir. Run: sudo chown $APP_UID:$APP_GID data"
|
|
fi
|
|
fi
|
|
chmod u+rwx "$data_dir" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
require_docker() {
|
|
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
|
COMPOSE_STYLE="docker"
|
|
elif command -v docker-compose >/dev/null 2>&1; then
|
|
COMPOSE_STYLE="docker-compose"
|
|
else
|
|
fail "Docker Compose was not found."
|
|
return 1
|
|
fi
|
|
if command -v docker >/dev/null 2>&1 && ! docker info >/dev/null 2>&1; then
|
|
fail "Docker is installed but not reachable. Check service/user permissions."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
compose() {
|
|
if [ "$COMPOSE_STYLE" = "docker" ]; then
|
|
docker compose "$@"
|
|
else
|
|
docker-compose "$@"
|
|
fi
|
|
}
|
|
|
|
run_compose() {
|
|
if [ "$COMPOSE_STYLE" = "docker" ]; then
|
|
color "+ docker compose $*" "$DIM"
|
|
else
|
|
color "+ docker-compose $*" "$DIM"
|
|
fi
|
|
printf '\n'
|
|
compose "$@"
|
|
}
|
|
|
|
start_stack() {
|
|
pull="${1:-1}"
|
|
section "Start Docker stack"
|
|
require_docker || return 1
|
|
if [ "$pull" = "1" ]; then
|
|
(cd "$TARGET_DIR" && run_compose pull) || return 1
|
|
fi
|
|
(cd "$TARGET_DIR" && run_compose up -d) || return 1
|
|
(cd "$TARGET_DIR" && run_compose ps) || true
|
|
ok "Stack command completed."
|
|
}
|
|
|
|
validate_stack() {
|
|
section "Validate stack"
|
|
require_docker || return 1
|
|
(cd "$TARGET_DIR" && run_compose ps) || true
|
|
(cd "$TARGET_DIR" && run_compose logs --tail 80 migrate) || true
|
|
ok "Validation commands completed."
|
|
}
|
|
|
|
volume_exists() {
|
|
docker volume inspect "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
volume_is_empty() {
|
|
docker run --rm -v "$1:/data" alpine sh -c \
|
|
'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1
|
|
}
|
|
|
|
copy_volume_if_safe() {
|
|
source_volume="$1"
|
|
target_volume="$2"
|
|
required="${3:-0}"
|
|
|
|
if ! volume_exists "$source_volume"; then
|
|
if [ "$required" = "1" ]; then
|
|
fail "Source Docker volume not found: $source_volume"
|
|
return 1
|
|
fi
|
|
warn "Skipping $source_volume: source volume was not found."
|
|
return 0
|
|
fi
|
|
|
|
if ! volume_exists "$target_volume"; then
|
|
if [ "$required" = "1" ]; then
|
|
fail "Target Docker volume not found: $target_volume"
|
|
return 1
|
|
fi
|
|
warn "Skipping $target_volume: target volume was not created by this profile."
|
|
return 0
|
|
fi
|
|
|
|
if ! volume_is_empty "$target_volume"; then
|
|
if [ "$required" = "1" ]; then
|
|
warn "Target volume $target_volume is already not empty."
|
|
warn "It may already be migrated, or the target stack may have been started with an empty database."
|
|
if confirm "Continue without copying the old database volume?" 0; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
fi
|
|
warn "Skipping $target_volume: target volume is already not empty."
|
|
return 0
|
|
fi
|
|
|
|
run_label="docker run --rm -v $source_volume:/from:ro -v $target_volume:/to alpine sh -c 'cd /from && cp -a . /to/'"
|
|
color "+ $run_label" "$DIM"
|
|
printf '\n'
|
|
docker run --rm \
|
|
-v "$source_volume:/from:ro" \
|
|
-v "$target_volume:/to" \
|
|
alpine sh -c 'cd /from && cp -a . /to/' || return 1
|
|
ok "Copied $source_volume -> $target_volume"
|
|
}
|
|
|
|
stop_known_legacy_containers() {
|
|
section "Stop old containers"
|
|
stopped=0
|
|
for container in $KNOWN_LEGACY_CONTAINERS; do
|
|
if docker inspect "$container" >/dev/null 2>&1; then
|
|
if docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q '^true$'; then
|
|
docker stop "$container" >/dev/null || true
|
|
fi
|
|
docker rm "$container" >/dev/null || true
|
|
info "Stopped/removed $container"
|
|
stopped=1
|
|
fi
|
|
done
|
|
if [ "$stopped" = "0" ]; then
|
|
info "No known old containers found."
|
|
fi
|
|
}
|
|
|
|
wait_target_postgres() {
|
|
section "Wait for target PostgreSQL"
|
|
attempt=1
|
|
while [ "$attempt" -le 30 ]; do
|
|
if (cd "$TARGET_DIR" && compose exec -T postgres sh -c \
|
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1); then
|
|
ok "PostgreSQL is ready."
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
attempt=$((attempt + 1))
|
|
done
|
|
fail "Target PostgreSQL did not become ready."
|
|
return 1
|
|
}
|
|
|
|
download_importer() {
|
|
importer="$TARGET_DIR/$IMPORTER_CACHE_PATH"
|
|
mkdir -p "$(dirname "$importer")"
|
|
if [ -f "$importer" ] && confirm "Use cached importer at $importer?" 1 >&2; then
|
|
printf '%s' "$importer"
|
|
return 0
|
|
fi
|
|
url=$(raw_url "$SOURCE_REPO" "$SOURCE_REF" "backend/scripts/import_legacy.py")
|
|
tmp="$TARGET_DIR/.import_legacy.$$"
|
|
download_to "$url" "$tmp" || {
|
|
rm -f "$tmp"
|
|
fail "Could not download $url"
|
|
return 1
|
|
}
|
|
if [ -f "$importer" ]; then
|
|
backup=$(backup_path "$importer")
|
|
cp "$importer" "$backup"
|
|
info "Backed up $importer to $(basename "$backup")" >&2
|
|
fi
|
|
mv "$tmp" "$importer"
|
|
ok "Cached importer at $importer" >&2
|
|
printf '%s' "$importer"
|
|
}
|
|
|
|
local_target_dsn() {
|
|
printf 'postgresql://%s:%s@postgres:5432/%s' "$POSTGRES_USER_VALUE" "$POSTGRES_PASSWORD_VALUE" "$POSTGRES_DB_VALUE"
|
|
}
|
|
|
|
target_webhook_base_url() {
|
|
public_url=$(env_get WEBHOOK_PUBLIC_URL "")
|
|
if [ -n "$public_url" ]; then
|
|
printf '%s' "$public_url" | sed 's:/*$::'
|
|
return 0
|
|
fi
|
|
host=$(env_get WEBHOOK_HOST "")
|
|
if [ -n "$host" ]; then
|
|
printf 'https://%s' "$host" | sed 's:/*$::'
|
|
return 0
|
|
fi
|
|
printf ''
|
|
}
|
|
|
|
remnashop_webhook_checklist() {
|
|
section "Update external webhooks"
|
|
base_url=$(target_webhook_base_url)
|
|
if [ -z "$base_url" ]; then
|
|
warn "Could not determine webhook base URL from .env. Set WEBHOOK_HOST or WEBHOOK_PUBLIC_URL, then use WEBHOOK_BASE_URL + paths below."
|
|
base_url="WEBHOOK_BASE_URL"
|
|
fi
|
|
|
|
info "Set these URLs in external dashboards after the migration:"
|
|
printf ' Remnawave Panel -> WEBHOOK_URL: %s/webhook/panel\n' "$base_url"
|
|
panel_secret=$(env_get PANEL_WEBHOOK_SECRET "")
|
|
if [ -n "$panel_secret" ]; then
|
|
printf ' Remnawave Panel -> webhook secret: %s\n' "$(mask_secret "$panel_secret")"
|
|
else
|
|
warn "PANEL_WEBHOOK_SECRET is empty; set it in Minishop and in Remnawave Panel."
|
|
fi
|
|
printf ' YooKassa merchant cabinet -> HTTP notifications URL: %s/webhook/yookassa\n' "$base_url"
|
|
printf ' WATA merchant dashboard -> webhook/callback URL: %s/webhook/wata\n' "$base_url"
|
|
printf ' CryptoBot/Crypto Pay app -> webhook URL: %s/webhook/cryptopay\n' "$base_url"
|
|
printf ' Heleket merchant dashboard -> payment webhook/callback URL: %s/webhook/heleket\n' "$base_url"
|
|
printf ' FreeKassa shop settings -> notification/result URL: %s/webhook/freekassa\n' "$base_url"
|
|
printf ' Platega merchant/project settings -> webhook URL: %s/webhook/platega\n' "$base_url"
|
|
printf ' Telegram webhook: %s/tg/webhook (configured automatically on bot startup)\n' "$base_url"
|
|
}
|
|
|
|
run_import_command() {
|
|
dry="$1"
|
|
set -- run --rm \
|
|
-v "$IMPORTER_PATH:/app/backend/scripts/import_legacy.py:ro"
|
|
if [ -n "$SOURCE_ENV_PATH" ]; then
|
|
set -- "$@" -v "$SOURCE_ENV_PATH:/tmp/remnashop.env:ro"
|
|
fi
|
|
if [ -n "$TARIFF_MAP_PATH" ]; then
|
|
set -- "$@" -v "$TARIFF_MAP_PATH:/tmp/tariff-map.json:ro"
|
|
fi
|
|
set -- "$@" backend python backend/scripts/import_legacy.py \
|
|
--source-type remnashop \
|
|
--source-dsn "$SOURCE_DSN" \
|
|
--source-schema "$SOURCE_SCHEMA" \
|
|
--target-dsn "$TARGET_DSN"
|
|
if [ -n "$SOURCE_ENV_PATH" ]; then
|
|
set -- "$@" --source-env-file /tmp/remnashop.env
|
|
fi
|
|
if [ -n "$TARIFF_MAP_PATH" ]; then
|
|
set -- "$@" --tariff-map-json /tmp/tariff-map.json
|
|
fi
|
|
if [ "$dry" = "1" ]; then
|
|
set -- "$@" --dry-run
|
|
fi
|
|
(cd "$TARGET_DIR" && run_compose "$@")
|
|
}
|
|
|
|
choose_legacy_source() {
|
|
choose "Source bot" "1" "1|2|3" \
|
|
"1. Remnashop - import users, subscriptions, payments, provider settings and promo codes." \
|
|
"2. Old remnawave-tg-shop - upgrade an old compatible database/volume." \
|
|
"3. Skip migration"
|
|
case "$CHOICE_VALUE" in
|
|
1) LEGACY_SOURCE="remnashop" ;;
|
|
2) LEGACY_SOURCE="remnawave-tg-shop" ;;
|
|
3) LEGACY_SOURCE="skip" ;;
|
|
esac
|
|
}
|
|
|
|
ensure_github_source_for_importer() {
|
|
if [ -n "$SOURCE_REPO" ] && [ -n "$SOURCE_REF" ]; then
|
|
return 0
|
|
fi
|
|
github_source
|
|
}
|
|
|
|
run_remnashop_migration() {
|
|
section "Remnashop migration"
|
|
ENV_PATH="$TARGET_DIR/.env"
|
|
if [ ! -f "$ENV_PATH" ]; then
|
|
fail ".env not found. Install or generate configuration first."
|
|
return 1
|
|
fi
|
|
ensure_github_source_for_importer || return 1
|
|
require_docker || return 1
|
|
POSTGRES_USER_VALUE="$(env_get POSTGRES_USER '')"
|
|
POSTGRES_PASSWORD_VALUE="$(env_get POSTGRES_PASSWORD '')"
|
|
POSTGRES_DB_VALUE="$(env_get POSTGRES_DB '')"
|
|
|
|
prompt_value "Source Remnashop PostgreSQL DSN" "${REMNASHOP_SOURCE_DSN:-}" 1 0 ""
|
|
SOURCE_DSN="$PROMPT_VALUE"
|
|
prompt_value "Source schema" "public" 1 0 ""
|
|
SOURCE_SCHEMA="$PROMPT_VALUE"
|
|
prompt_value "Optional source Remnashop .env path (empty to skip)" "${REMNASHOP_SOURCE_ENV_FILE:-}" 0 0 ""
|
|
SOURCE_ENV_PATH="$PROMPT_VALUE"
|
|
if [ -n "$SOURCE_ENV_PATH" ]; then
|
|
source_env_dir=$(dirname "$SOURCE_ENV_PATH")
|
|
if [ ! -d "$source_env_dir" ]; then
|
|
fail "Source .env directory not found: $source_env_dir"
|
|
return 1
|
|
fi
|
|
SOURCE_ENV_PATH=$(cd "$source_env_dir" && pwd)/$(basename "$SOURCE_ENV_PATH")
|
|
if [ ! -f "$SOURCE_ENV_PATH" ]; then
|
|
fail "Source Remnashop .env not found: $SOURCE_ENV_PATH"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
choose "Target database" "1" "1|2" \
|
|
"1. This Docker Compose stack database (recommended)" \
|
|
"2. Manual target DSN"
|
|
if [ "$CHOICE_VALUE" = "1" ]; then
|
|
TARGET_DSN="$(local_target_dsn)"
|
|
info "Target DSN points to the Compose postgres service."
|
|
else
|
|
prompt_value "Target PostgreSQL DSN" "" 1 0 ""
|
|
TARGET_DSN="$PROMPT_VALUE"
|
|
fi
|
|
|
|
prompt_value "Optional tariff map JSON path (empty to skip)" "" 0 0 ""
|
|
TARIFF_MAP_PATH="$PROMPT_VALUE"
|
|
if [ -n "$TARIFF_MAP_PATH" ]; then
|
|
tariff_map_dir=$(dirname "$TARIFF_MAP_PATH")
|
|
if [ ! -d "$tariff_map_dir" ]; then
|
|
fail "Tariff map directory not found: $tariff_map_dir"
|
|
return 1
|
|
fi
|
|
TARIFF_MAP_PATH=$(cd "$tariff_map_dir" && pwd)/$(basename "$TARIFF_MAP_PATH")
|
|
if [ ! -f "$TARIFF_MAP_PATH" ]; then
|
|
fail "Tariff map not found: $TARIFF_MAP_PATH"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
IMPORTER_PATH="$(download_importer)" || return 1
|
|
|
|
section "Dry-run import"
|
|
if ! run_import_command 1; then
|
|
fail "Dry-run failed. Fix the connection/settings before importing."
|
|
return 1
|
|
fi
|
|
if ! confirm "Apply this migration for real?" 0; then
|
|
warn "Migration not applied."
|
|
return 0
|
|
fi
|
|
|
|
section "Apply import"
|
|
run_import_command 0 || return 1
|
|
remnashop_webhook_checklist
|
|
if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then
|
|
(cd "$TARGET_DIR" && run_compose restart backend worker) || true
|
|
fi
|
|
ok "Migration completed."
|
|
}
|
|
|
|
run_target_schema_migrations() {
|
|
section "Apply target schema migrations"
|
|
require_docker || return 1
|
|
(cd "$TARGET_DIR" && run_compose run --rm migrate) || return 1
|
|
ok "Schema migrations completed."
|
|
}
|
|
|
|
prepare_compose_without_starting_apps() {
|
|
section "Prepare target Compose stack"
|
|
require_docker || return 1
|
|
(cd "$TARGET_DIR" && run_compose up --no-start) || return 1
|
|
}
|
|
|
|
run_tgshop_volume_migration() {
|
|
section "Old remnawave-tg-shop volume migration"
|
|
warn "This path copies the old PostgreSQL Docker volume into the new Minishop volume."
|
|
warn "Old volumes are not deleted; keep them until you verify the new stack."
|
|
|
|
if confirm "Stop known old/current containers before copying volumes?" 1; then
|
|
stop_known_legacy_containers || return 1
|
|
(cd "$TARGET_DIR" && run_compose down) || true
|
|
fi
|
|
|
|
prepare_compose_without_starting_apps || return 1
|
|
copy_volume_if_safe "$OLD_TGSHOP_DB_VOLUME" "$NEW_MINISHOP_DB_VOLUME" 1 || return 1
|
|
copy_volume_if_safe "$OLD_TGSHOP_CADDY_DATA_VOLUME" "$NEW_MINISHOP_CADDY_DATA_VOLUME" 0 || return 1
|
|
copy_volume_if_safe "$OLD_TGSHOP_CADDY_CONFIG_VOLUME" "$NEW_MINISHOP_CADDY_CONFIG_VOLUME" 0 || return 1
|
|
|
|
if confirm "Start the new stack and let migrate apply schema changes now?" 1; then
|
|
start_stack 0 || return 1
|
|
(cd "$TARGET_DIR" && run_compose logs --tail 120 migrate) || true
|
|
else
|
|
warn "Stack was prepared but not started. Run docker compose up -d later."
|
|
fi
|
|
}
|
|
|
|
run_tgshop_dsn_migration() {
|
|
section "Old remnawave-tg-shop DSN migration"
|
|
warn "This wizard path dumps the old PostgreSQL database, restores it into target Compose PostgreSQL, then runs Minishop schema migrations."
|
|
warn "The target database will be dropped and recreated before restore."
|
|
|
|
if ! confirm "Replace target database with the source dump?" 0; then
|
|
warn "Migration not applied."
|
|
return 0
|
|
fi
|
|
|
|
prompt_value "Source remnawave-tg-shop PostgreSQL DSN" "${LEGACY_TGSHOP_SOURCE_DSN:-}" 1 0 ""
|
|
SOURCE_DSN="$PROMPT_VALUE"
|
|
|
|
require_docker || return 1
|
|
POSTGRES_USER_VALUE="$(env_get POSTGRES_USER '')"
|
|
POSTGRES_PASSWORD_VALUE="$(env_get POSTGRES_PASSWORD '')"
|
|
POSTGRES_DB_VALUE="$(env_get POSTGRES_DB '')"
|
|
TARGET_DSN="$(local_target_dsn)"
|
|
|
|
section "Start target PostgreSQL"
|
|
(cd "$TARGET_DIR" && run_compose stop backend worker frontend migrate) || true
|
|
(cd "$TARGET_DIR" && run_compose up -d postgres redis) || return 1
|
|
wait_target_postgres || return 1
|
|
|
|
section "Reset target database"
|
|
(cd "$TARGET_DIR" && run_compose exec -T postgres sh -c \
|
|
'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"') || return 1
|
|
|
|
section "Dump and restore old database"
|
|
(cd "$TARGET_DIR" && run_compose run --rm --no-deps \
|
|
-e "SOURCE_DSN=$SOURCE_DSN" \
|
|
-e "TARGET_DSN=$TARGET_DSN" \
|
|
backend sh -lc \
|
|
'pg_dump --clean --if-exists --no-owner --no-privileges "$SOURCE_DSN" | psql "$TARGET_DSN"') || return 1
|
|
|
|
run_target_schema_migrations || return 1
|
|
if confirm "Start the full stack now?" 1; then
|
|
start_stack 0 || return 1
|
|
fi
|
|
}
|
|
|
|
run_remnawave_tg_shop_migration() {
|
|
section "Old remnawave-tg-shop migration"
|
|
ENV_PATH="$TARGET_DIR/.env"
|
|
if [ ! -f "$ENV_PATH" ]; then
|
|
fail ".env not found. Install or generate configuration first."
|
|
return 1
|
|
fi
|
|
require_docker || return 1
|
|
|
|
choose "Migration method" "1" "1|2|3" \
|
|
"1. Copy old Docker volumes on this host (recommended for old compose installs)." \
|
|
"2. Dump from a source PostgreSQL DSN and restore into this compose stack." \
|
|
"3. Skip migration"
|
|
case "$CHOICE_VALUE" in
|
|
1) run_tgshop_volume_migration ;;
|
|
2) run_tgshop_dsn_migration ;;
|
|
3) return 0 ;;
|
|
esac
|
|
}
|
|
|
|
run_selected_legacy_migration() {
|
|
case "$LEGACY_SOURCE" in
|
|
remnashop)
|
|
run_remnashop_migration
|
|
;;
|
|
remnawave-tg-shop)
|
|
run_remnawave_tg_shop_migration
|
|
;;
|
|
skip|"")
|
|
return 0
|
|
;;
|
|
esac
|
|
}
|
|
|
|
installation_directory() {
|
|
prompt_value "Install directory" "${MINISHOP_INSTALL_DIR:-$(pwd)}" 1 0 ""
|
|
mkdir -p "$(dirname "$PROMPT_VALUE")"
|
|
TARGET_DIR=$(cd "$(dirname "$PROMPT_VALUE")" && pwd)/$(basename "$PROMPT_VALUE")
|
|
mkdir -p "$TARGET_DIR"
|
|
}
|
|
|
|
github_source() {
|
|
prompt_value "GitHub repository" "$DEFAULT_REPO" 1 0 ""
|
|
SOURCE_REPO="$PROMPT_VALUE"
|
|
prompt_value "Git ref/branch/tag for raw files" "$DEFAULT_REF" 1 0 ""
|
|
SOURCE_REF="$PROMPT_VALUE"
|
|
}
|
|
|
|
install_flow() {
|
|
with_migration="$1"
|
|
LEGACY_SOURCE=""
|
|
installation_directory || return 1
|
|
github_source || return 1
|
|
choose_profile
|
|
ENV_PATH="$TARGET_DIR/.env"
|
|
if [ -f "$ENV_PATH" ]; then
|
|
warn "Existing .env found at $ENV_PATH; wizard will preserve unknown values."
|
|
fi
|
|
prompt_common_env || return 1
|
|
download_profile_files || return 1
|
|
write_env_file || return 1
|
|
mkdir -p "$TARGET_DIR/$INSTALL_STATE_DIR"
|
|
prepare_data_mount || return 1
|
|
if [ "$with_migration" = "1" ]; then
|
|
choose_legacy_source
|
|
elif confirm "Run a migration from another bot now?" 0; then
|
|
choose_legacy_source
|
|
fi
|
|
|
|
case "$LEGACY_SOURCE" in
|
|
remnawave-tg-shop)
|
|
run_selected_legacy_migration
|
|
;;
|
|
remnashop)
|
|
if confirm "Start Docker Compose stack before Remnashop import?" 1; then
|
|
start_stack || return 1
|
|
else
|
|
warn "Remnashop import needs the target stack database. Skipping import."
|
|
return 0
|
|
fi
|
|
run_selected_legacy_migration
|
|
;;
|
|
*)
|
|
if confirm "Start Docker Compose stack now?" 1; then
|
|
start_stack || return 1
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
migration_only_flow() {
|
|
LEGACY_SOURCE=""
|
|
installation_directory || return 1
|
|
choose_legacy_source
|
|
[ "$LEGACY_SOURCE" = "skip" ] && return 0
|
|
prepare_data_mount || return 1
|
|
case "$LEGACY_SOURCE" in
|
|
remnashop)
|
|
github_source || return 1
|
|
;;
|
|
esac
|
|
run_selected_legacy_migration
|
|
}
|
|
|
|
download_only_flow() {
|
|
installation_directory || return 1
|
|
github_source || return 1
|
|
choose_profile
|
|
download_profile_files
|
|
}
|
|
|
|
health_flow() {
|
|
installation_directory || return 1
|
|
validate_stack
|
|
}
|
|
|
|
main_menu() {
|
|
while :; do
|
|
banner
|
|
choose "Main menu" "1" "1|2|3|4|5|6" \
|
|
"1. Install new stack" \
|
|
"2. Install new stack and run migration" \
|
|
"3. Run migration only" \
|
|
"4. Download/update deployment files only" \
|
|
"5. Validate current stack" \
|
|
"6. Exit"
|
|
case "$CHOICE_VALUE" in
|
|
1) install_flow 0 ;;
|
|
2) install_flow 1 ;;
|
|
3) migration_only_flow ;;
|
|
4) download_only_flow ;;
|
|
5) health_flow ;;
|
|
6) printf 'Bye.\n'; return 0 ;;
|
|
esac
|
|
status=$?
|
|
if [ "$status" -ne 0 ]; then
|
|
fail "Step failed with status $status."
|
|
fi
|
|
pause
|
|
done
|
|
}
|
|
|
|
case "${1:-}" in
|
|
-h|--help)
|
|
print_help
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
main_menu
|