#!/usr/bin/env bash set -euo pipefail INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}" REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}" BRANCH="${VPN_PROXY_BRANCH:-master}" COMPOSE_FILE="docker-compose.client.yml" DEFAULT_PROXY_PORT="8080" REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}" REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}" CLIENT_CONTAINER_NAME="vpn-proxy-client" log() { printf '[vpn-proxy-client] %s\n' "$*" } die() { printf '[vpn-proxy-client] error: %s\n' "$*" >&2 exit 1 } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required" } is_valid_port() { case "$1" in ''|*[!0-9]*) return 1 ;; esac [ "$1" -ge 1024 ] && [ "$1" -le 65535 ] } ask_proxy_port() { local value="" if [ -n "$REQUESTED_PROXY_PORT" ]; then if ! is_valid_port "$REQUESTED_PROXY_PORT"; then die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535" fi printf '%s\n' "$REQUESTED_PROXY_PORT" return 0 fi if [ -r /dev/tty ]; then while true; do printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty IFS= read -r value /dev/tty done fi if ! is_valid_port "$DEFAULT_PROXY_PORT"; then die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535" fi printf '%s\n' "$DEFAULT_PROXY_PORT" } port_range_end() { local start="$1" local end="$((start + 10))" if [ "$end" -gt 65535 ]; then end=65535 fi printf '%s\n' "$end" } published_port_conflicts() { local port="$1" local line while IFS= read -r line; do [ -n "$line" ] || continue case "$line" in "${CLIENT_CONTAINER_NAME}"$'\t'*) ;; *) printf '%s\n' "$line" ;; esac done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}') } proxy_port_conflicts() { local start="$1" local end local port local conflicts end="$(port_range_end "$start")" for port in $(seq "$start" "$end"); do conflicts="$(published_port_conflicts "$port")" if [ -n "$conflicts" ]; then printf 'port %s: %s\n' "$port" "$conflicts" fi done } assert_proxy_port_available() { local port="$1" local conflicts conflicts="$(proxy_port_conflicts "$port")" if [ -z "$conflicts" ]; then return 0 fi printf '[vpn-proxy-client] proxy port range %s-%s is already used:\n%s\n' \ "$port" "$(port_range_end "$port")" "$conflicts" >&2 die "choose another proxy port with VPN_PROXY_CLIENT_PORT= or stop the conflicting container" } assert_single_port_available() { local label="$1" local port="$2" local conflicts conflicts="$(published_port_conflicts "$port")" if [ -z "$conflicts" ]; then return 0 fi printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \ "$label" "$port" "$conflicts" >&2 die "choose another ${label} port or stop the conflicting container" } first_free_port() { local start="$1" local port for port in $(seq "$start" 65535); do if [ -z "$(published_port_conflicts "$port")" ]; then printf '%s\n' "$port" return 0 fi done return 1 } choose_ui_port() { local value="$1" local suggested if ! is_valid_port "$value"; then die "CLIENT_UI_PORT must be a port from 1024 to 65535" fi if [ -z "$(published_port_conflicts "$value")" ]; then printf '%s\n' "$value" return 0 fi if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then assert_single_port_available "UI" "$value" fi suggested="$(first_free_port "$((value + 1))" || true)" suggested="${suggested:-3457}" while true; do printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty IFS= read -r value /dev/tty done } assert_ui_outside_proxy_range() { if [ "$UI_PORT" -ge "$PROXY_PORT" ] && [ "$UI_PORT" -le "$PROXY_PORT_END" ]; then die "UI port ${UI_PORT} overlaps proxy port range ${PROXY_PORT}-${PROXY_PORT_END}" fi } wait_for_client_ui() { local ui_port="${UI_PORT:-3456}" local ui_url="http://127.0.0.1:${ui_port}/api/state" local attempt for attempt in $(seq 1 30); do if curl -fsS "$ui_url" >/dev/null 2>&1; then return 0 fi sleep 1 done printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2 printf '[vpn-proxy-client] docker compose status:\n' >&2 docker compose -f "$COMPOSE_FILE" ps >&2 || true printf '\n[vpn-proxy-client] recent service logs:\n' >&2 docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true die "client UI is not ready; see Docker status and logs above" } set_env_value() { local key="$1" local value="$2" local tmp tmp="$(mktemp)" if [ -f .env ] && grep -q "^${key}=" .env; then awk -v key="$key" -v value="$value" ' BEGIN { prefix = key "=" } index($0, prefix) == 1 { print key "=" value; next } { print } ' .env > "$tmp" else [ -f .env ] && cat .env > "$tmp" printf '%s=%s\n' "$key" "$value" >> "$tmp" fi mv "$tmp" .env } get_env_value() { local key="$1" [ -f .env ] || return 0 awk -v key="$key" ' BEGIN { prefix = key "=" } index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit } ' .env } if [[ "$(uname -s)" != "Darwin" ]]; then die "this installer is intended for macOS" fi need git need docker need curl docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required" docker info >/dev/null 2>&1 || die "Docker Desktop is not running" if [[ -d "$INSTALL_DIR/.git" ]]; then log "updating $INSTALL_DIR" git -C "$INSTALL_DIR" fetch origin "$BRANCH" git -C "$INSTALL_DIR" checkout "$BRANCH" git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH" else log "cloning $REPO_URL#$BRANCH to $INSTALL_DIR" mkdir -p "$(dirname "$INSTALL_DIR")" git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" fi cd "$INSTALL_DIR" if [[ ! -f .env && -f .env.example ]]; then cp .env.example .env fi PROXY_PORT="$(ask_proxy_port)" assert_proxy_port_available "$PROXY_PORT" PROXY_PORT_END="$(port_range_end "$PROXY_PORT")" UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}" UI_PORT="${UI_PORT:-3456}" UI_PORT="$(choose_ui_port "$UI_PORT")" assert_ui_outside_proxy_range set_env_value APP_MODE client set_env_value CLIENT_UI_PORT "$UI_PORT" set_env_value CLIENT_PROXY_PORT "$PROXY_PORT" set_env_value CLIENT_PROXY_PORT_START "$PROXY_PORT" set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END" set_env_value PROXY_PORT "$PROXY_PORT" log "UI port: http://127.0.0.1:${UI_PORT}" log "proxy port: 127.0.0.1:${PROXY_PORT} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})" log "building and starting Docker client" docker compose -f "$COMPOSE_FILE" up -d --build wait_for_client_ui cat <