Files
telegram-web-proxy/install-shared-vps.sh

412 lines
16 KiB
Bash

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
# Telegram WEB Proxy + WEB PANEL PROXY for a VPS where Caddy already runs
# inside Docker. The existing Caddyfile is preserved and extended with one
# additional hostname; no second public web server is installed.
readonly INSTALLER_VERSION="1.0.0"
readonly PANEL_VERSION="v2.0.2"
readonly PANEL_ARCHIVE_URL="https://github.com/POLESNIESOVETI12/web-panel-proxy/archive/refs/tags/${PANEL_VERSION}.zip"
readonly PANEL_ARCHIVE_SHA256="ac2258323b71bde13eb45314a0a70449561774697c5e4d3603791e459c55e7bb"
readonly DEFAULT_ASSET_BASE_URL="https://git.chardb.ru/AlexChard/telegram-web-proxy/raw/branch/main/site"
readonly SITE_INDEX_SHA256="fd65d104b54a3c79c74cbc5a73dfd4e1504a01dffc2d5b453aa724c4b0445924"
readonly SITE_CSS_SHA256="511759e08f0ade1f1e96d46ca2a938a2d9ec15a40111c93a3df4c17be6ef5688"
readonly SITE_FAVICON_SHA256="b70149593f184d3acec15b23fa5f4226cb90782b87092b739977b8d7d67ede03"
readonly RELAY_BRIDGE_PORT="18080"
readonly PANEL_BRIDGE_PORT="18090"
DOMAIN="gg.chardwork.ru"
EMAIL=""
CADDY_CONTAINER="adguard-caddy"
CADDY_STACK_DIR="/opt/stacks/adguard-dns"
ASSET_BASE_URL="${ASSET_BASE_URL:-$DEFAULT_ASSET_BASE_URL}"
SITE_SOURCE_DIR="${SITE_SOURCE_DIR:-}"
WORK_DIR=""
BACKUP_DIR=""
CADDYFILE=""
DOH_URL="https://dns.chardwork.ru/dns-query?dns=AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE"
DOH_BASELINE=""
INSTALL_COMPLETE=0
TEMP_HOSTS_MARKER="# telegram-web-proxy temporary DNS"
say() { printf '\n==> %s\n' "$*"; }
die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Usage:
sudo bash install-shared-vps.sh --domain gg.example.com --email admin@example.com
Options:
--domain HOSTNAME Public WEB Proxy hostname.
--email ADDRESS Email used by Caddy/ACME.
--caddy-container NAME Existing Caddy container (default: adguard-caddy).
--caddy-stack DIR Existing Caddy stack directory.
--asset-base-url URL Alternate base URL for the static site files.
EOF
}
valid_domain() {
[[ "$1" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] &&
[[ "$1" == *.* ]] && [[ "$1" != *..* ]]
}
valid_email() {
[[ "$1" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]
}
cleanup() {
if [[ -w /etc/hosts ]] && grep -Fq "$TEMP_HOSTS_MARKER" /etc/hosts 2>/dev/null; then
sed -i "\\|$TEMP_HOSTS_MARKER|d" /etc/hosts
fi
[[ -z "$WORK_DIR" || ! -d "$WORK_DIR" ]] || rm -rf -- "$WORK_DIR"
}
rollback_caddy() {
local code=$?
trap - ERR
if [[ "$INSTALL_COMPLETE" != 1 && -n "$BACKUP_DIR" && -s "$BACKUP_DIR/Caddyfile" ]]; then
printf '\nInstallation stopped; restoring the previous Caddyfile...\n' >&2
cp -a "$BACKUP_DIR/Caddyfile" "$CADDYFILE"
docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || true
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || true
fi
cleanup
exit "$code"
}
trap cleanup EXIT
trap rollback_caddy ERR
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="${2:-}"; shift 2 ;;
--email) EMAIL="${2:-}"; shift 2 ;;
--caddy-container) CADDY_CONTAINER="${2:-}"; shift 2 ;;
--caddy-stack) CADDY_STACK_DIR="${2:-}"; shift 2 ;;
--asset-base-url) ASSET_BASE_URL="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
[[ "$EUID" -eq 0 ]] || die "Run as root."
[[ "$(uname -m)" == "x86_64" ]] || die "An x86_64 VPS is required."
valid_domain "$DOMAIN" || die "Invalid domain: $DOMAIN"
if [[ -z "$EMAIL" ]]; then
read -r -p "ACME email: " EMAIL </dev/tty
fi
valid_email "$EMAIL" || die "Invalid email: $EMAIL"
command -v docker >/dev/null 2>&1 || die "Docker is required."
docker inspect "$CADDY_CONTAINER" >/dev/null 2>&1 || die "Caddy container '$CADDY_CONTAINER' was not found."
[[ "$(docker inspect -f '{{.State.Running}}' "$CADDY_CONTAINER")" == true ]] || die "Caddy container is not running."
CADDYFILE="${CADDY_STACK_DIR}/Caddyfile"
[[ -s "$CADDYFILE" ]] || die "Existing Caddyfile was not found: $CADDYFILE"
[[ -s "${CADDY_STACK_DIR}/compose.yaml" ]] || die "Existing compose.yaml was not found: ${CADDY_STACK_DIR}/compose.yaml"
# Choose the gateway of the Docker network that contains the running Caddy.
CADDY_GATEWAY="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{if .Gateway}}{{println .Gateway}}{{end}}{{end}}' "$CADDY_CONTAINER" | awk 'NF{print; exit}')"
[[ "$CADDY_GATEWAY" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "Could not determine the Caddy Docker gateway."
for port in "$RELAY_BRIDGE_PORT" "$PANEL_BRIDGE_PORT" 2398 8080 8081 8090; do
if ss -lnt | grep -Eq "[:.]${port}\\b"; then
die "Required local port ${port} is already in use."
fi
done
say "Checking DNS and the existing DoH service"
VPS_IP="$(curl -4fsS --max-time 10 https://api.ipify.org)"
DNS_IP="$(getent ahostsv4 "$DOMAIN" | awk 'NR==1{print $1}')"
PUBLIC_DNS_IP="$(dig +short A "$DOMAIN" @1.1.1.1 | tail -n1)"
[[ "$PUBLIC_DNS_IP" == "$VPS_IP" ]] ||
die "$DOMAIN resolves through public DNS to ${PUBLIC_DNS_IP:-nothing}, but this VPS is $VPS_IP."
if [[ "$DNS_IP" != "$VPS_IP" ]]; then
grep -Eq "(^|[[:space:]])${DOMAIN//./\\.}([[:space:]]|$)" /etc/hosts &&
die "/etc/hosts already contains an unexpected entry for $DOMAIN."
printf '%s %s %s\n' "$VPS_IP" "$DOMAIN" "$TEMP_HOSTS_MARKER" >> /etc/hosts
echo " The VPS resolver is still caching NXDOMAIN; a temporary local entry was added."
fi
DOH_BASELINE="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 8 "$DOH_URL" || true)"
[[ "$DOH_BASELINE" == 200 ]] || die "Existing DoH check failed before installation (HTTP ${DOH_BASELINE:-error})."
say "Creating a recoverable backup"
BACKUP_DIR="/opt/backups/telegram-web-proxy/$(date -u +%Y%m%dT%H%M%SZ)"
install -d -m 0700 "$BACKUP_DIR"
cp -a "$CADDYFILE" "$BACKUP_DIR/Caddyfile"
cp -a "${CADDY_STACK_DIR}/compose.yaml" "$BACKUP_DIR/compose.yaml"
docker inspect "$CADDY_CONTAINER" > "$BACKUP_DIR/caddy-container-inspect.json"
chmod 0600 "$BACKUP_DIR"/*
say "Installing verified dependencies"
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=600 update
apt-get -o DPkg::Lock::Timeout=600 install -y --no-install-recommends \
ca-certificates curl unzip socat python3 openssl git dnsutils nftables \
build-essential libssl-dev util-linux zlib1g-dev
PANEL_ADMIN="admin"
PANEL_PASSWORD="$(openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-')"
[[ ${#PANEL_PASSWORD} -ge 20 ]] || die "Could not generate the panel password."
WORK_DIR="$(mktemp -d /tmp/gg-web-proxy.XXXXXXXX)"
ARCHIVE="$WORK_DIR/web-panel-proxy.zip"
curl --fail --silent --show-error --location \
--proto '=https' --proto-redir '=https' --tlsv1.2 \
--output "$ARCHIVE" "$PANEL_ARCHIVE_URL"
[[ "$(sha256sum "$ARCHIVE" | awk '{print $1}')" == "$PANEL_ARCHIVE_SHA256" ]] ||
die "WEB PANEL PROXY archive checksum mismatch."
unzip -q "$ARCHIVE" -d "$WORK_DIR/source"
SOURCE_DIR="$WORK_DIR/source/web-panel-proxy-${PANEL_VERSION#v}"
for file in install-final.sh install-webproxy-core.sh install-panel.sh uninstall-web-proxy.sh; do
[[ -s "$SOURCE_DIR/$file" ]] || die "Installation package is incomplete: $file"
done
say "Preparing the Docker-Caddy compatibility layer"
install -d -m 0755 /etc/caddy /usr/local/lib/telegram-web-proxy
if [[ -e /etc/caddy/Caddyfile && ! -L /etc/caddy/Caddyfile ]]; then
die "/etc/caddy/Caddyfile already exists and is not a managed symlink."
fi
ln -sfn "$CADDYFILE" /etc/caddy/Caddyfile
cat > /usr/local/bin/caddy <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
for _ in \$(seq 1 60); do
if [[ \$(/usr/bin/docker inspect -f '{{.State.Running}}' ${CADDY_CONTAINER} 2>/dev/null || true) == true ]]; then
break
fi
sleep 1
done
[[ \$(/usr/bin/docker inspect -f '{{.State.Running}}' ${CADDY_CONTAINER} 2>/dev/null || true) == true ]] || {
echo "Caddy container ${CADDY_CONTAINER} is not running." >&2
exit 1
}
exec /usr/bin/docker exec ${CADDY_CONTAINER} caddy "\$@"
EOF
chmod 0755 /usr/local/bin/caddy
cat > /etc/systemd/system/caddy.service <<EOF
[Unit]
Description=Caddy compatibility service for ${CADDY_CONTAINER}
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
ExecStart=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
ExecStop=/bin/true
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/tproxy-caddy-relay-bridge.service <<EOF
[Unit]
Description=Private bridge from Docker Caddy to Telegram WEB Proxy relay
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:${RELAY_BRIDGE_PORT},bind=${CADDY_GATEWAY},fork,reuseaddr TCP:127.0.0.1:8080
Restart=always
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/tproxy-caddy-panel-bridge.service <<EOF
[Unit]
Description=Private bridge from Docker Caddy to Telegram WEB Proxy panel
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:${PANEL_BRIDGE_PORT},bind=${CADDY_GATEWAY},fork,reuseaddr TCP:127.0.0.1:8090
Restart=always
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
install -d -m 0755 /etc/systemd/system/caddy.service.d
cat > /etc/systemd/system/caddy.service.d/tproxy.conf <<EOF
[Service]
Environment=TPROXY_HOSTNAME=${DOMAIN}
Environment=TPROXY_SITE_ROOT=/srv/tproxy-site
Environment=ACME_EMAIL=${EMAIL}
EOF
systemctl daemon-reload
systemctl enable --now caddy.service tproxy-caddy-relay-bridge.service tproxy-caddy-panel-bridge.service
say "Adapting the pinned installer for the existing Docker Caddy"
python3 - "$SOURCE_DIR/install-webproxy-core.sh" "$SOURCE_DIR/install-panel.sh" "$CADDY_GATEWAY" "$RELAY_BRIDGE_PORT" "$PANEL_BRIDGE_PORT" <<'PY'
import re
import sys
core_path, panel_path, gateway, relay_port, panel_port = sys.argv[1:]
relay = f"{gateway}:{relay_port}"
panel = f"{gateway}:{panel_port}"
core = open(core_path, encoding="utf-8").read()
for old, new in (
("check_install_port 80 caddy", "check_install_port 80 docker-proxy"),
("check_install_port 443 caddy", "check_install_port 443 docker-proxy"),
):
if core.count(old) != 1:
raise SystemExit(f"Unexpected upstream core layout: {old}")
core = core.replace(old, new)
needle = ' "$CANONICAL_CADDY"\n\nif [[ "$REUSE_CADDY" == "1" ]]; then'
replacement = (
' "$CANONICAL_CADDY"\n'
f"sed -i 's#reverse_proxy 127.0.0.1:8080#reverse_proxy {relay}#g' \"$CANONICAL_CADDY\"\n\n"
'if [[ "$REUSE_CADDY" == "1" ]]; then'
)
if core.count(needle) != 1:
raise SystemExit("Could not locate the canonical Caddy patch point")
core = core.replace(needle, replacement)
credentials_block = '''else
echo
read -r -p "Panel administrator login [admin]: " PANEL_ADMIN
PANEL_ADMIN="${PANEL_ADMIN:-admin}"
while true; do
read -r -s -p "Panel administrator password: " PANEL_PASS
echo
if [[ ${#PANEL_PASS} -lt 3 ]]; then
echo "Password must contain at least 3 characters."
continue
fi
break
done
printf '%s\\n%s\\n' "$PANEL_ADMIN" "$PANEL_PASS" > /etc/web-proxy-panel/install-credentials
chmod 0600 /etc/web-proxy-panel/install-credentials
unset PANEL_PASS
fi'''
credentials_replacement = '''else
PANEL_ADMIN="${WEBPROXY_PANEL_ADMIN:-admin}"
PANEL_PASS="${WEBPROXY_PANEL_PASSWORD:?WEBPROXY_PANEL_PASSWORD is required}"
[[ ${#PANEL_PASS} -ge 12 ]] || die "Generated panel password is too short."
printf '%s\\n%s\\n' "$PANEL_ADMIN" "$PANEL_PASS" > /etc/web-proxy-panel/install-credentials
chmod 0600 /etc/web-proxy-panel/install-credentials
unset PANEL_PASS
echo " Generated panel credentials received securely."
fi'''
if core.count(credentials_block) != 1:
raise SystemExit("Could not locate the panel credentials block")
core = core.replace(credentials_block, credentials_replacement)
open(core_path, "w", encoding="utf-8").write(core)
panel_src = open(panel_path, encoding="utf-8").read()
old_route = '" reverse_proxy 127.0.0.1:8090\\n"'
new_route = f'" reverse_proxy {panel}\\n"'
if panel_src.count(old_route) != 1:
raise SystemExit("Could not locate the panel Caddy route")
panel_src = panel_src.replace(old_route, new_route)
old_match = r"r'(?m)^\s*reverse_proxy 127\.0\.0\.1:8080\s*\{'"
escaped = re.escape(relay)
new_match = "r'(?m)^\\s*reverse_proxy " + escaped + r"\s*\{'"
if panel_src.count(old_match) != 1:
raise SystemExit("Could not locate the relay Caddy matcher")
panel_src = panel_src.replace(old_match, new_match)
open(panel_path, "w", encoding="utf-8").write(panel_src)
PY
chmod 0700 "$SOURCE_DIR"/*.sh
say "Installing the proxy and its private management panel"
WEBPROXY_PANEL_ADMIN="$PANEL_ADMIN" \
WEBPROXY_PANEL_PASSWORD="$PANEL_PASSWORD" \
bash "$SOURCE_DIR/install-final.sh"
say "Installing the neutral Gradient Garden site"
install -d -o root -g tproxy -m 0750 /srv/tproxy-site
download_site_file() {
local name="$1" expected="$2"
if [[ -n "$SITE_SOURCE_DIR" ]]; then
[[ -f "$SITE_SOURCE_DIR/$name" ]] || die "Local site file is missing: $SITE_SOURCE_DIR/$name"
cp "$SITE_SOURCE_DIR/$name" "/srv/tproxy-site/${name}.new"
else
curl --fail --silent --show-error --location \
--proto '=https' --proto-redir '=https' --tlsv1.2 \
--output "/srv/tproxy-site/${name}.new" "${ASSET_BASE_URL}/${name}"
fi
[[ "$(sha256sum "/srv/tproxy-site/${name}.new" | awk '{print $1}')" == "$expected" ]] ||
die "Site file checksum mismatch: $name"
mv "/srv/tproxy-site/${name}.new" "/srv/tproxy-site/${name}"
}
download_site_file index.html "$SITE_INDEX_SHA256"
download_site_file styles.css "$SITE_CSS_SHA256"
download_site_file favicon.svg "$SITE_FAVICON_SHA256"
chown root:tproxy /srv/tproxy-site/index.html /srv/tproxy-site/styles.css /srv/tproxy-site/favicon.svg
chmod 0640 /srv/tproxy-site/index.html /srv/tproxy-site/styles.css /srv/tproxy-site/favicon.svg
install -d -m 0755 /etc/systemd/system/tproxy-panel.service.d /etc/systemd/system/tproxy-server.service.d /etc/systemd/system/mtproxy.service.d
cat > /etc/systemd/system/tproxy-panel.service.d/resources.conf <<'EOF'
[Service]
MemoryMax=128M
CPUQuota=25%
EOF
cat > /etc/systemd/system/tproxy-server.service.d/resources.conf <<'EOF'
[Service]
MemoryMax=256M
CPUQuota=100%
EOF
cat > /etc/systemd/system/mtproxy.service.d/resources.conf <<'EOF'
[Service]
MemoryMax=256M
CPUQuota=100%
EOF
systemctl daemon-reload
systemctl restart tproxy-panel.service tproxy-server.service mtproxy.service
systemctl reload caddy.service
say "Final checks"
docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
[[ "$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 10 "https://${DOMAIN}/")" == 200 ]] || die "Public site check failed."
[[ "$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 8 "$DOH_URL")" == 200 ]] || die "Existing DoH check failed after installation."
[[ "$(curl -fsS --max-time 5 http://127.0.0.1:8081/healthz)" == ok ]] || die "Relay health check failed."
[[ "$(curl -fsS --max-time 5 http://127.0.0.1:8081/readyz)" == ready ]] || die "Relay readiness check failed."
PANEL_PATH="$(sed -n 's/^Environment=WEBPROXY_PANEL_PATH=//p' /etc/systemd/system/tproxy-panel.service | head -n1)"
PRIMARY_SECRET="$(cat /etc/web-proxy-panel/primary-secret)"
cat > /root/telegram-web-proxy-access.txt <<EOF
Domain: ${DOMAIN}
Panel: https://${DOMAIN}${PANEL_PATH}/login
Panel login: ${PANEL_ADMIN}
Panel password: ${PANEL_PASSWORD}
Primary secret: ${PRIMARY_SECRET}
Telegram link: tg://webproxy?server=${DOMAIN}&port=443&secret=${PRIMARY_SECRET}
Public site: https://${DOMAIN}/
Backup created before installation: ${BACKUP_DIR}
EOF
chmod 0600 /root/telegram-web-proxy-access.txt
INSTALL_COMPLETE=1
trap - ERR
cleanup
printf '\n============================================================\n'
printf ' Telegram WEB Proxy is ready\n'
printf '============================================================\n'
printf ' Site: https://%s/\n' "$DOMAIN"
printf ' Panel: https://%s%s/login\n' "$DOMAIN" "$PANEL_PATH"
printf ' Link: tg://webproxy?server=%s&port=443&secret=%s\n' "$DOMAIN" "$PRIMARY_SECRET"
printf '\nPanel credentials and a private copy are saved in /root/telegram-web-proxy-access.txt\n'