Ground-Zerro / Phobos Public
Code Issues Pull requests Actions Releases View on GitHub ↗
8.2 KB bash
#!/usr/bin/env bash
set -euo pipefail

DEPLOY_DIR="${DEPLOY_DIR:-/opt/phoboswg}"
UI_PORT="${UI_PORT:-51831}"
WG_HOST="${WG_HOST:-}"
WG_EASY_IMAGE="${WG_EASY_IMAGE:-ghcr.io/ground-zerro/phobos:latest}"
REPO_RAW_BASE="${REPO_RAW_BASE:-https://raw.githubusercontent.com/Ground-Zerro/Phobos/main}"
COMPOSE_FILE="docker-compose.yml"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-phoboswg}"
export COMPOSE_PROJECT_NAME
INIT_ENABLED="${INIT_ENABLED:-false}"
INIT_USERNAME="${INIT_USERNAME:-admin}"
INIT_PASSWORD="${INIT_PASSWORD:-}"

log()  { printf '\e[1;34m==>\e[0m %s\n' "$*"; }
ok()   { printf '\e[1;32m  ✓\e[0m %s\n' "$*"; }
fail() { printf '\e[1;31mERROR:\e[0m %s\n' "$*" >&2; exit 1; }
trap 'printf "\e[1;31mERROR:\e[0m Deploy failed at line %s\n" "$LINENO" >&2' ERR

require_root() {
  [ "$(id -u)" -eq 0 ] || fail "Run as root: sudo bash $0"
}

detect_distro() {
  if [ -f /etc/os-release ]; then
    . /etc/os-release
    echo "${ID:-unknown}"
  else
    echo "unknown"
  fi
}

random_password() {
  local prev_pipefail password
  prev_pipefail="$(set -o | awk '$1=="pipefail"{print $2}')"
  set +o pipefail
  password="$(tr -dc 'A-Za-z0-9!@#%^*_+=' < /dev/urandom | head -c 20 || true)"
  if [ "$prev_pipefail" = "on" ]; then
    set -o pipefail
  fi
  [ -n "$password" ] || password="Phobos$(date +%s)Aa1!"
  printf '%s' "$password"
}

install_docker() {
  log "Installing Docker"
  local distro
  distro="$(detect_distro)"

  case "$distro" in
    ubuntu|debian)
      export DEBIAN_FRONTEND=noninteractive
      apt-get update -qq
      apt-get install -y -qq ca-certificates curl
      install -m 0755 -d /etc/apt/keyrings
      curl -fsSL "https://download.docker.com/linux/${distro}/gpg" -o /etc/apt/keyrings/docker.asc
      chmod a+r /etc/apt/keyrings/docker.asc
      echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${distro} $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
      apt-get update -qq
      apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
      ;;
    centos|rhel|fedora|rocky|almalinux)
      dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
      dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
      ;;
    *)
      curl -fsSL https://get.docker.com | sh
      ;;
  esac

  systemctl enable --now docker
  ok "Docker installed: $(docker --version)"
}

ensure_docker() {
  if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
    ok "Docker already present: $(docker --version)"
    return
  fi
  install_docker
}

fix_networkd_docker_bridge() {
  systemctl is-active --quiet systemd-networkd || return 0
  local net_drop="/etc/systemd/network/05-docker-unmanaged.network"
  local new_content="[Match]
Name=veth* docker0 br-*

[Link]
Unmanaged=yes"
  if [ "$(cat "$net_drop" 2>/dev/null)" = "$new_content" ]; then
    ok "systemd-networkd docker bridge fix already applied"
    return
  fi
  printf '%s\n' "$new_content" > "$net_drop"
  networkctl reload 2>/dev/null || systemctl restart systemd-networkd
  ok "systemd-networkd configured to ignore docker bridges"
}

load_wireguard_module() {
  if lsmod | grep -q wireguard 2>/dev/null; then
    ok "WireGuard kernel module already loaded"
    return
  fi
  if modprobe wireguard 2>/dev/null; then
    ok "WireGuard kernel module loaded"
  else
    log "Warning: could not load wireguard module — wireguard-go (userspace) will be used"
  fi
}

detect_public_ip() {
  local ip
  ip=$(
    curl -fsSL --connect-timeout 5 https://api.ipify.org 2>/dev/null ||
    curl -fsSL --connect-timeout 5 https://ifconfig.me 2>/dev/null ||
    curl -fsSL --connect-timeout 5 https://icanhazip.com 2>/dev/null ||
    true
  )
  echo "${ip:-127.0.0.1}"
}

download_stack_files() {
  log "Downloading deployment files"
  mkdir -p "$DEPLOY_DIR"
  curl -fsSL "${REPO_RAW_BASE}/docker-compose.yml" -o "${DEPLOY_DIR}/docker-compose.yml"
  ok "docker-compose.yml downloaded"
}

wait_healthy() {
  local ctr="${WAIT_HEALTHY_CONTAINER:-phobos}"
  local poll_s="${WAIT_HEALTHY_POLL_SEC:-2}"
  local timeout_s="${WAIT_HEALTHY_TIMEOUT_SEC:-360}"
  local start_ts now_ts elapsed i=0 status
  start_ts=$(date +%s)

  log "Waiting for container ${ctr} (poll ${poll_s}s, timeout ${timeout_s}s)"
  while :; do
    status=$(docker inspect "${ctr}" --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}nohealth{{end}}" 2>/dev/null || echo "missing")
    now_ts=$(date +%s)
    elapsed=$((now_ts - start_ts))

    case "$status" in
      healthy)
        ok "Container is healthy after ${elapsed}s"
        docker ps --filter "name=${ctr}" --format "  {{.Names}} | {{.Status}} | {{.Ports}}"
        return 0
        ;;
      unhealthy)
        fail "Container reported unhealthy after ${elapsed}s. Logs:\n$(docker logs --tail 120 "${ctr}" 2>/dev/null || true)"
        ;;
      missing)
        fail "Container is missing"
        ;;
      *)
        if [ $((i % 5)) -eq 0 ]; then
          printf '    status=%s elapsed=%ss/%ss\n' "${status}" "${elapsed}" "${timeout_s}"
        fi
        ;;
    esac

    if [ "${elapsed}" -ge "${timeout_s}" ]; then
      fail "Container did not become healthy in ${timeout_s}s (last status: ${status}). Logs:\n$(docker logs --tail 150 "${ctr}" 2>/dev/null || true)"
    fi

    i=$((i + 1))
    sleep "${poll_s}"
  done
}

require_root
ensure_docker
fix_networkd_docker_bridge
load_wireguard_module

if [ -z "$WG_HOST" ]; then
  log "Detecting public IP"
  WG_HOST="$(detect_public_ip)"
  ok "Public IP: $WG_HOST"
fi

if [ "$INIT_ENABLED" = "true" ] && [ -z "$INIT_PASSWORD" ]; then
  INIT_PASSWORD="$(random_password)"
fi

download_stack_files
cd "$DEPLOY_DIR"

log "Writing .env"
if [ "$INIT_ENABLED" = "true" ]; then
  cat > "${DEPLOY_DIR}/.env" <<EOF
WG_HOST=${WG_HOST}
WG_EASY_IMAGE=${WG_EASY_IMAGE}
INIT_ENABLED=true
INIT_USERNAME=${INIT_USERNAME}
INIT_PASSWORD=${INIT_PASSWORD}
INIT_HOST=${WG_HOST}
EOF
else
  cat > "${DEPLOY_DIR}/.env" <<EOF
WG_HOST=${WG_HOST}
WG_EASY_IMAGE=${WG_EASY_IMAGE}
INIT_ENABLED=false
EOF
fi
ok ".env written"

log "Pulling image"
docker compose -f "$COMPOSE_FILE" pull
ok "Image pulled"

log "Starting stack (compose=$COMPOSE_FILE)"
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
ok "Stack started"

wait_healthy

printf '\n'
printf '\e[1;32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\e[0m\n'
printf '\e[1;32m  PhobosWG deployed successfully\e[0m\n'
printf '\e[1;32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\e[0m\n'
printf '\n'

if [ "$INIT_ENABLED" = "true" ]; then
  printf '\e[1;33m  >>> Open in browser to log in: <<<\e[0m\n'
  printf '\e[1;37m  http://%s:%s/\e[0m\n' "$WG_HOST" "$UI_PORT"
  printf '\n'
  printf '  Username    : %s\n' "$INIT_USERNAME"
  printf '  Password    : %s\n' "$INIT_PASSWORD"
  printf '\n'
  printf '\e[0;33m  Note: to configure domain and TLS certificate go to\e[0m\n'
  printf '\e[0;33m  Admin → Interface after login.\e[0m\n'
  printf '\e[0;33m  Credentials above apply on first deploy only (new database).\e[0m\n'
else
  printf '\e[1;33m  >>> Open in browser to complete initial setup: <<<\e[0m\n'
  printf '\e[1;37m  http://%s:%s/\e[0m\n' "$WG_HOST" "$UI_PORT"
  printf '\n'
  printf '  The setup wizard will guide you through:\n'
  printf '    1. Create admin account (username + password)\n'
  printf '    2. Set server host (IP address or domain name)\n'
  printf '    3. Configure TLS certificate (self-signed / import / skip)\n'
fi

printf '\n'
printf '  Admin UI    : %s:%s\n' "$WG_HOST" "$UI_PORT"
printf '  Obfuscator  : UDP %s:51822-51921 (preset range)\n' "$WG_HOST"
printf '  Image       : %s\n' "$WG_EASY_IMAGE"
printf '  Deploy dir  : %s\n' "$DEPLOY_DIR"
printf '\e[1;32m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\e[0m\n'
printf '\n'
docker ps --format "  {{.Names}}  |  {{.Status}}  |  {{.Ports}}"
printf '\n'