#!/usr/bin/env bash set -Eeuo pipefail IFS=$'\n\t' export LC_ALL=C readonly SCRIPT_NAME="${0##*/}" readonly SCRIPT_VERSION="2.0.1" readonly BOOTSTRAP_SERVICE="warp-ipv4-bootstrap.service" readonly BOOTSTRAP_UNIT="/etc/systemd/system/${BOOTSTRAP_SERVICE}" readonly BOOTSTRAP_HELPER="/usr/local/libexec/warp-ipv4-bootstrap" readonly WARP_DROPIN_DIR="/etc/systemd/system/warp-svc.service.d" readonly WARP_DROPIN_FILE="${WARP_DROPIN_DIR}/20-ipv4-bootstrap.conf" readonly LEGACY_DROPIN_FILE="${WARP_DROPIN_DIR}/override.conf" readonly STATE_DIR="/var/lib/warp-ipv4-egress" readonly STATE_FILE="${STATE_DIR}/state.env" readonly BACKUP_DIR="/root/warp-ipv4-egress-backups" readonly ROLLBACK_SCRIPT="/root/warp-ipv4-egress-rollback.sh" readonly ROLLBACK_UNIT="warp-ipv4-egress-rollback-${$}" readonly LEGACY_ROLLBACK_UNIT="warp-ipv4-egress-rollback" readonly LOCK_FILE="/run/lock/warp-ipv4-egress-install.lock" DUMMY_IPV4="${DUMMY_IPV4:-192.168.254.2}" DUMMY_PREFIX="${DUMMY_PREFIX:-32}" DUMMY_GATEWAY="${DUMMY_GATEWAY:-192.168.254.1}" DUMMY_METRIC="${DUMMY_METRIC:-65000}" FRESH_INSTALL="${FRESH_INSTALL:-1}" ALLOW_IPV4_SSH="${ALLOW_IPV4_SSH:-0}" CHECK_ONLY=0 IFACE="${IFACE:-}" VERSION_CODENAME="" PRETTY_NAME="" ARCH="" IP_BIN="" OLD_IFACE="" OLD_DUMMY_IPV4="" OLD_DUMMY_PREFIX="" OLD_DUMMY_GATEWAY="" OLD_DUMMY_METRIC="" TMP_DIR="" BACKUP_FILE="" DIAG_FILE="" TEMP_TUN="" ROLLBACK_ARMED=0 WARP_MASKED_BY_SCRIPT=0 WARP_ACCEPT_TOS=0 INSTALL_SUCCESS=0 LOCK_FD=9 APT_OPTS=( -o Acquire::ForceIPv6=true -o Acquire::Retries=3 -o Dpkg::Use-Pty=0 ) usage() { cat <&2 } collect_diagnostics() { local reason="${1:-unknown error}" [[ ${EUID:-1} -eq 0 ]] || return 0 if ! command -v systemctl >/dev/null 2>&1 \ && ! command -v warp-cli >/dev/null 2>&1; then return 0 fi DIAG_FILE="/root/warp-ipv4-egress-diagnostics-$(date '+%Y%m%d-%H%M%S').log" { printf 'Reason: %s\n' "$reason" printf 'Date: %s\n' "$(date -Is 2>/dev/null || date)" printf 'Script: %s %s\n' "$SCRIPT_NAME" "$SCRIPT_VERSION" printf 'Interface: %s\n' "${IFACE:-unknown}" printf '\n===== OS =====\n' cat /etc/os-release 2>/dev/null || true uname -a 2>/dev/null || true printf '\n===== IPv4 addresses/routes =====\n' ip -4 addr show 2>/dev/null || true ip -4 route show table all 2>/dev/null || true printf '\n===== IPv6 addresses/routes =====\n' ip -6 addr show 2>/dev/null || true ip -6 route show table all 2>/dev/null || true printf '\n===== warp-cli version/status =====\n' warp-cli --version 2>&1 || true timeout 10 warp-cli --accept-tos status 2>&1 \ || timeout 10 warp-cli status 2>&1 \ || true printf '\n===== warp-svc status =====\n' systemctl status warp-svc.service --no-pager 2>&1 || true printf '\n===== bootstrap status =====\n' systemctl status "$BOOTSTRAP_SERVICE" --no-pager 2>&1 || true printf '\n===== warp-svc journal =====\n' journalctl -u warp-svc.service -n 180 --no-pager 2>&1 || true } >"$DIAG_FILE" 2>&1 || true chmod 0600 "$DIAG_FILE" 2>/dev/null || true warn "Diagnostik disimpan di ${DIAG_FILE}" } fatal() { local message="$*" printf '\033[1;31m[ERROR]\033[0m %s\n' "$message" >&2 collect_diagnostics "$message" || true exit 1 } on_error() { local exit_code=$? local line_no="${BASH_LINENO[0]:-unknown}" local failed_command="${BASH_COMMAND:-unknown}" [[ $- == *e* ]] || return 0 trap - ERR set +e printf '\033[1;31m[ERROR]\033[0m Kegagalan tak terduga (exit %s) di baris %s.\n' \ "$exit_code" "$line_no" >&2 printf ' Perintah: %s\n' "$failed_command" >&2 collect_diagnostics "unexpected exit ${exit_code} at line ${line_no}: ${failed_command}" || true return 0 } on_exit() { local exit_code=$? trap - EXIT INT TERM HUP set +e if [[ -n "$TEMP_TUN" ]]; then ip link delete "$TEMP_TUN" >/dev/null 2>&1 || true fi if (( exit_code != 0 && ROLLBACK_ARMED == 1 )); then warn "Instalasi tidak selesai. Menjalankan rollback untuk memulihkan jalur IPv6/SSH." systemctl stop "${ROLLBACK_UNIT}.timer" "${ROLLBACK_UNIT}.service" >/dev/null 2>&1 || true bash "$ROLLBACK_SCRIPT" >/dev/null 2>&1 || true fi if (( WARP_MASKED_BY_SCRIPT == 1 )); then systemctl unmask --runtime warp-svc.service >/dev/null 2>&1 || true WARP_MASKED_BY_SCRIPT=0 fi if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then rm -rf "$TMP_DIR" fi exit "$exit_code" } trap on_error ERR trap on_exit EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP parse_args() { while (($#)); do case "$1" in --check-only) CHECK_ONLY=1 ;; --fresh) FRESH_INSTALL=1 ;; --no-fresh) FRESH_INSTALL=0 ;; -h|--help) usage exit 0 ;; *) printf 'Opsi tidak dikenal: %s\n\n' "$1" >&2 usage >&2 exit 2 ;; esac shift done } require_root() { [[ ${EUID} -eq 0 ]] || fatal "Jalankan script ini sebagai root." } validate_boolean() { [[ "$1" == "0" || "$1" == "1" ]] } validate_ipv4() { local value="$1" local IFS=. local -a octets local octet read -r -a octets <<<"$value" [[ ${#octets[@]} -eq 4 ]] || return 1 for octet in "${octets[@]}"; do [[ "$octet" =~ ^[0-9]+$ ]] || return 1 ((10#$octet >= 0 && 10#$octet <= 255)) || return 1 done } validate_iface() { [[ "$1" =~ ^[a-zA-Z0-9_.:-]+$ ]] } check_inputs() { validate_boolean "$FRESH_INSTALL" || fatal "FRESH_INSTALL harus 0 atau 1." validate_boolean "$ALLOW_IPV4_SSH" || fatal "ALLOW_IPV4_SSH harus 0 atau 1." validate_ipv4 "$DUMMY_IPV4" || fatal "DUMMY_IPV4 tidak valid: $DUMMY_IPV4" validate_ipv4 "$DUMMY_GATEWAY" || fatal "DUMMY_GATEWAY tidak valid: $DUMMY_GATEWAY" [[ "$DUMMY_PREFIX" =~ ^[0-9]+$ ]] \ && ((DUMMY_PREFIX >= 0 && DUMMY_PREFIX <= 32)) \ || fatal "DUMMY_PREFIX harus 0-32." [[ "$DUMMY_METRIC" =~ ^[0-9]+$ ]] \ && ((DUMMY_METRIC >= 1 && DUMMY_METRIC <= 4294967295)) \ || fatal "DUMMY_METRIC tidak valid." } check_platform() { [[ -r /etc/os-release ]] || fatal "/etc/os-release tidak ditemukan." source /etc/os-release [[ "${ID:-}" == "ubuntu" ]] \ || fatal "Script ini khusus Ubuntu. Terdeteksi: ${PRETTY_NAME:-unknown}" case "${VERSION_CODENAME:-}" in jammy|noble|resolute) ;; *) fatal "Ubuntu ${VERSION_CODENAME:-unknown} tidak didukung oleh repository WARP resmi. Target: jammy/noble/resolute." ;; esac ARCH="$(dpkg --print-architecture 2>/dev/null || true)" [[ -n "$ARCH" ]] || fatal "Arsitektur dpkg tidak dapat dideteksi." ok "OS: ${PRETTY_NAME} (${VERSION_CODENAME}), arsitektur ${ARCH}" } check_systemd() { [[ -d /run/systemd/system ]] \ || fatal "systemd tidak aktif sebagai init system. Script ini membutuhkan systemd." command -v systemctl >/dev/null 2>&1 || fatal "systemctl tidak ditemukan." command -v systemd-run >/dev/null 2>&1 || fatal "systemd-run tidak ditemukan." local state state="$(systemctl is-system-running 2>/dev/null || true)" case "$state" in running|degraded|starting) ;; *) fatal "Status systemd tidak siap: ${state:-unknown}" ;; esac ok "systemd siap (${state})." } acquire_lock() { install -d -m 0755 /run/lock exec 9>"$LOCK_FILE" flock -n "$LOCK_FD" \ || fatal "Ada proses instalasi WARP IPv4 lain yang sedang berjalan." } bootstrap_dependencies() { local -a required=(apt-get apt-cache dpkg dpkg-query systemctl systemd-run flock) local cmd for cmd in "${required[@]}"; do command -v "$cmd" >/dev/null 2>&1 || fatal "Perintah wajib tidak ditemukan: $cmd" done if (( CHECK_ONLY == 1 )); then local -a check_commands=(ip curl gpg getent timeout awk grep sed find tar df install) local -a missing=() for cmd in "${check_commands[@]}"; do command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") done ((${#missing[@]} == 0)) \ || fatal "Mode --check-only tidak memasang paket. Perintah yang belum ada: ${missing[*]}" return 0 fi log "Tahap 1/6: memastikan dependensi dasar tersedia melalui IPv6" export DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" update apt-get "${APT_OPTS[@]}" install -y --no-install-recommends \ ca-certificates curl gnupg lsb-release iproute2 util-linux for cmd in ip curl gpg getent timeout awk grep sed find tar df install; do command -v "$cmd" >/dev/null 2>&1 || fatal "Dependensi terpasang tetapi perintah tidak ditemukan: $cmd" done IP_BIN="$(command -v ip)" ok "Dependensi dasar siap." } detect_interface() { if [[ -n "$IFACE" ]]; then validate_iface "$IFACE" || fatal "Nama interface tidak valid: $IFACE" ip link show dev "$IFACE" >/dev/null 2>&1 \ || fatal "Interface $IFACE tidak ditemukan." return 0 fi IFACE="$( ip -6 route show default 2>/dev/null \ | awk '!found {for (i=1; i<=NF; i++) if ($i=="dev") {d=$(i+1); if (d !~ /^(lo|CloudflareWARP|warp|wg|tun)/) {print d; found=1; break}}}' )" if [[ -z "$IFACE" ]]; then IFACE="$( ip -6 route get 2606:4700:4700::1111 2>/dev/null \ | awk '!found {for (i=1; i<=NF; i++) if ($i=="dev") {print $(i+1); found=1; break}}' )" fi if [[ -z "$IFACE" ]]; then IFACE="$( ip -6 -o addr show scope global 2>/dev/null \ | awk '!found && $2 !~ /^(lo|CloudflareWARP|warp|wg|tun)/ {print $2; found=1}' )" fi [[ -n "$IFACE" ]] \ || fatal "Interface IPv6 utama tidak dapat dideteksi. Gunakan IFACE=eth0." validate_iface "$IFACE" || fatal "Nama interface hasil deteksi tidak valid: $IFACE" ip link show dev "$IFACE" >/dev/null 2>&1 \ || fatal "Interface hasil deteksi tidak ada: $IFACE" } read_state_value() { local key="$1" local file="$2" awk -F= -v wanted="$key" ' $1 == wanted && !found { value = substr($0, index($0, "=") + 1) if (value !~ /[[:space:]]/) { print value found = 1 } } ' "$file" 2>/dev/null } read_old_managed_state() { local unit_line if [[ -r "$STATE_FILE" ]]; then OLD_IFACE="$(read_state_value IFACE "$STATE_FILE")" OLD_DUMMY_IPV4="$(read_state_value DUMMY_IPV4 "$STATE_FILE")" OLD_DUMMY_PREFIX="$(read_state_value DUMMY_PREFIX "$STATE_FILE")" OLD_DUMMY_GATEWAY="$(read_state_value DUMMY_GATEWAY "$STATE_FILE")" OLD_DUMMY_METRIC="$(read_state_value DUMMY_METRIC "$STATE_FILE")" fi if [[ -r "$BOOTSTRAP_UNIT" ]]; then while IFS= read -r unit_line; do if [[ -z "$OLD_DUMMY_IPV4" \ && "$unit_line" =~ ^ExecStart=.*[/]ip[[:space:]]+-4[[:space:]]+addr[[:space:]]+replace[[:space:]]+([0-9.]+)/([0-9]+)[[:space:]]+dev[[:space:]]+([a-zA-Z0-9_.:-]+)$ ]]; then OLD_DUMMY_IPV4="${BASH_REMATCH[1]}" OLD_DUMMY_PREFIX="${BASH_REMATCH[2]}" OLD_IFACE="${BASH_REMATCH[3]}" fi if [[ -z "$OLD_DUMMY_GATEWAY" \ && "$unit_line" =~ ^ExecStart=.*[/]ip[[:space:]]+-4[[:space:]]+route[[:space:]]+replace[[:space:]]+default[[:space:]]+via[[:space:]]+([0-9.]+)[[:space:]]+dev[[:space:]]+([a-zA-Z0-9_.:-]+).*metric[[:space:]]+([0-9]+)$ ]]; then OLD_DUMMY_GATEWAY="${BASH_REMATCH[1]}" [[ -n "$OLD_IFACE" ]] || OLD_IFACE="${BASH_REMATCH[2]}" OLD_DUMMY_METRIC="${BASH_REMATCH[3]}" fi done <"$BOOTSTRAP_UNIT" fi if [[ -n "$OLD_IFACE" ]] && ! validate_iface "$OLD_IFACE"; then OLD_IFACE="" fi if [[ -n "$OLD_DUMMY_IPV4" ]] && ! validate_ipv4 "$OLD_DUMMY_IPV4"; then OLD_DUMMY_IPV4="" fi if [[ -n "$OLD_DUMMY_GATEWAY" ]] && ! validate_ipv4 "$OLD_DUMMY_GATEWAY"; then OLD_DUMMY_GATEWAY="" fi if [[ -n "$OLD_DUMMY_PREFIX" && ! "$OLD_DUMMY_PREFIX" =~ ^[0-9]+$ ]]; then OLD_DUMMY_PREFIX="" fi if [[ -n "$OLD_DUMMY_METRIC" && ! "$OLD_DUMMY_METRIC" =~ ^[0-9]+$ ]]; then OLD_DUMMY_METRIC="" fi } is_managed_ipv4_address() { local iface="$1" local cidr="$2" [[ "$iface" == "$IFACE" && "$cidr" == "${DUMMY_IPV4}/${DUMMY_PREFIX}" ]] \ && return 0 [[ -n "$OLD_IFACE" && -n "$OLD_DUMMY_IPV4" && -n "$OLD_DUMMY_PREFIX" \ && "$iface" == "$OLD_IFACE" \ && "$cidr" == "${OLD_DUMMY_IPV4}/${OLD_DUMMY_PREFIX}" ]] \ && return 0 [[ "$iface" =~ ^CloudflareWARP ]] && return 0 return 1 } is_managed_default_route() { local route="$1" [[ "$route" == *"via ${DUMMY_GATEWAY} dev ${IFACE}"* ]] && return 0 [[ -n "$OLD_DUMMY_GATEWAY" && -n "$OLD_IFACE" \ && "$route" == *"via ${OLD_DUMMY_GATEWAY} dev ${OLD_IFACE}"* ]] \ && return 0 [[ "$route" =~ dev[[:space:]]+CloudflareWARP ]] && return 0 return 1 } check_ipv6_only_target() { local iface cidr route local found_unmanaged=0 while IFS=' ' read -r iface cidr; do [[ -n "$iface" && -n "$cidr" ]] || continue if ! is_managed_ipv4_address "$iface" "$cidr"; then warn "IPv4 global tidak dikelola script: ${cidr} pada ${iface}" found_unmanaged=1 fi done < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $2, $4}') while IFS= read -r route; do [[ -n "$route" ]] || continue if ! is_managed_default_route "$route"; then warn "Default route IPv4 tidak dikelola script: ${route}" found_unmanaged=1 fi done < <(ip -4 route show default 2>/dev/null) ((found_unmanaged == 0)) \ || fatal "Host memiliki IPv4 asli/route IPv4 lain. Script ini hanya untuk VPS IPv6-only." ok "Tidak ditemukan IPv4 native yang akan bentrok." } check_ssh_safety() { local client_ip="" client_port="" server_ip="" server_port="" if [[ -n "${SSH_CONNECTION:-}" ]]; then IFS=' ' read -r client_ip client_port server_ip server_port <<<"$SSH_CONNECTION" if [[ "$server_ip" == *:* ]]; then ok "Sesi SSH terdeteksi melalui IPv6 (${server_ip})." elif (( ALLOW_IPV4_SSH == 1 )); then warn "Sesi SSH terlihat memakai IPv4 (${server_ip}); ALLOW_IPV4_SSH=1 aktif." else fatal "Sesi SSH terlihat memakai IPv4 (${server_ip}). Gunakan console/noVNC atau set ALLOW_IPV4_SSH=1 dengan risiko putus koneksi." fi else warn "SSH_CONNECTION tidak tersedia. Pastikan console/noVNC tetap terbuka selama instalasi." fi } check_ipv6_host_state() { local route6 global_v6 disable_all disable_iface free_kb global_v6="$( ip -6 -o addr show dev "$IFACE" scope global 2>/dev/null \ | awk '!found {print $4; found=1}' )" [[ -n "$global_v6" ]] \ || fatal "Interface ${IFACE} tidak memiliki IPv6 global." route6="$(ip -6 route get 2606:4700:4700::1111 2>/dev/null || true)" [[ -n "$route6" ]] || fatal "Tidak ada route IPv6 ke Internet." grep -Eq "dev[[:space:]]+${IFACE}([[:space:]]|$)" <<<"$route6" \ || fatal "Route IPv6 utama tidak melalui ${IFACE}: ${route6}" ip -6 route get 2606:4700:102::3 >/dev/null 2>&1 \ || fatal "Tidak ada route IPv6 ke endpoint WARP 2606:4700:102::3." disable_all="$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || echo 1)" disable_iface="$(cat "/proc/sys/net/ipv6/conf/${IFACE}/disable_ipv6" 2>/dev/null || echo 1)" [[ "$disable_all" == "0" && "$disable_iface" == "0" ]] \ || fatal "IPv6 dinonaktifkan melalui sysctl pada host/interface." free_kb="$(df -Pk / | awk 'NR==2 {print $4}')" [[ "$free_kb" =~ ^[0-9]+$ ]] || fatal "Ruang disk bebas tidak dapat dibaca." ((free_kb >= 307200)) \ || fatal "Ruang disk bebas kurang dari 300 MiB (${free_kb} KiB)." ok "IPv6 native aktif pada ${IFACE}: ${global_v6}" ok "Ruang disk memadai: $((free_kb / 1024)) MiB bebas." } check_tun_capability() { [[ -c /dev/net/tun ]] \ || fatal "/dev/net/tun tidak tersedia. Aktifkan TUN/TAP dari panel provider VPS." [[ -r /dev/net/tun && -w /dev/net/tun ]] \ || fatal "/dev/net/tun tidak dapat diakses baca/tulis oleh root." if (( CHECK_ONLY == 1 )); then ok "Device TUN tersedia (uji pembuatan interface dilewati pada --check-only)." return 0 fi TEMP_TUN="wpft$((RANDOM % 10000))" if ! ip tuntap add dev "$TEMP_TUN" mode tun >/dev/null 2>&1; then TEMP_TUN="" fatal "TUN ada tetapi kernel/container menolak pembuatan interface. Periksa CAP_NET_ADMIN/provider VPS." fi ip link delete "$TEMP_TUN" >/dev/null 2>&1 \ || fatal "Interface TUN preflight tidak dapat dibersihkan." TEMP_TUN="" ok "TUN dan CAP_NET_ADMIN siap." } curl_ipv6_probe() { local url="$1" timeout 30 curl -6 -fsS \ --connect-timeout 10 \ --max-time 25 \ --retry 2 \ --retry-delay 2 \ "$url" >/dev/null } check_ipv6_dns_https() { local http_code getent ahostsv6 pkg.cloudflareclient.com 2>/dev/null | grep -q ':' \ || fatal "DNS tidak mengembalikan AAAA untuk pkg.cloudflareclient.com." getent ahostsv6 engage.cloudflareclient.com 2>/dev/null | grep -q ':' \ || fatal "DNS tidak mengembalikan AAAA untuk engage.cloudflareclient.com." curl_ipv6_probe "https://www.cloudflare.com/cdn-cgi/trace" \ || fatal "HTTPS IPv6 ke Cloudflare tidak berfungsi." curl_ipv6_probe "https://pkg.cloudflareclient.com/" \ || fatal "Repository Cloudflare tidak dapat diakses melalui IPv6." http_code="$( timeout 30 curl -6 -sS -o /dev/null -w '%{http_code}' \ --connect-timeout 10 --max-time 25 \ https://engage.cloudflareclient.com/ 2>/dev/null \ || true )" [[ -n "$http_code" && "$http_code" != "000" ]] \ || fatal "Endpoint engage.cloudflareclient.com tidak dapat dijangkau melalui IPv6/TCP 443." ok "DNS dan HTTPS IPv6 ke Cloudflare/repository berfungsi." } prepare_repository_and_package() { local key_ascii key_gpg source_tmp candidate policy_output policy_rc local -a reinstall_opt=() log "Tahap 2/6: memvalidasi repository dan paket WARP sebelum pembersihan" TMP_DIR="$(mktemp -d /tmp/warp-ipv4-egress.XXXXXX)" key_ascii="${TMP_DIR}/cloudflare-pubkey.gpg" key_gpg="${TMP_DIR}/cloudflare-keyring.gpg" source_tmp="${TMP_DIR}/cloudflare-client.list" timeout 45 curl -6 -fsSL \ --connect-timeout 10 \ --max-time 40 \ --retry 3 \ --retry-delay 2 \ https://pkg.cloudflareclient.com/pubkey.gpg \ -o "$key_ascii" \ || fatal "GPG key Cloudflare gagal diunduh melalui IPv6." gpg --batch --yes --dearmor --output "$key_gpg" "$key_ascii" \ || fatal "GPG key Cloudflare gagal diproses." [[ -s "$key_gpg" ]] || fatal "Keyring Cloudflare kosong." printf 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ %s main\n' \ "$VERSION_CODENAME" >"$source_tmp" install -m 0644 "$key_gpg" /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg install -m 0644 "$source_tmp" /etc/apt/sources.list.d/cloudflare-client.list export DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" update \ || fatal "apt-get update repository gagal melalui IPv6." ok "Indeks repository selesai diperbarui." policy_output="" policy_rc=0 set +e policy_output="$(apt-cache policy cloudflare-warp 2>&1)" policy_rc=$? set -e if ((policy_rc != 0)); then printf '%s\n' "$policy_output" >&2 fatal "apt-cache policy cloudflare-warp gagal (exit ${policy_rc})." fi candidate="$( awk '$1 == "Candidate:" && !found {print $2; found=1}' \ <<<"$policy_output" )" [[ -n "$candidate" && "$candidate" != "(none)" ]] \ || { printf '%s\n' "$policy_output" >&2 fatal "Paket cloudflare-warp tidak tersedia untuk ${VERSION_CODENAME}/${ARCH}." } ok "Kandidat paket WARP ditemukan: ${candidate}" if dpkg-query -W -f='${Status}' cloudflare-warp 2>/dev/null \ | grep -q '^install ok installed$'; then reinstall_opt=(--reinstall) fi apt-get "${APT_OPTS[@]}" install -y --download-only --no-install-recommends \ "${reinstall_opt[@]}" cloudflare-warp \ || fatal "Paket/dependensi WARP tidak dapat diunduh penuh melalui IPv6." ok "Paket WARP kandidat ${candidate} tersedia dan dapat diunduh melalui IPv6." } backup_existing_state() { local timestamp local -a paths=() local -a rel_paths=() local path for path in \ "$BOOTSTRAP_UNIT" \ "$BOOTSTRAP_HELPER" \ "$WARP_DROPIN_FILE" \ "$LEGACY_DROPIN_FILE" \ "$STATE_DIR" \ /etc/cloudflare-warp \ /var/lib/cloudflare-warp \ /etc/apt/sources.list.d/cloudflare-client.list \ /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg; do [[ -e "$path" || -L "$path" ]] && paths+=("$path") done ((${#paths[@]} > 0)) || return 0 install -d -m 0700 "$BACKUP_DIR" timestamp="$(date '+%Y%m%d-%H%M%S')" BACKUP_FILE="${BACKUP_DIR}/preinstall-${timestamp}.tar.gz" for path in "${paths[@]}"; do rel_paths+=("${path#/}") done umask 077 tar -C / -czf "$BACKUP_FILE" --ignore-failed-read "${rel_paths[@]}" \ || fatal "Backup state lama gagal dibuat." chmod 0600 "$BACKUP_FILE" ok "Backup konfigurasi/state lama: ${BACKUP_FILE}" } stop_warp_safely() { if command -v warp-cli >/dev/null 2>&1; then timeout 10 warp-cli --accept-tos disconnect >/dev/null 2>&1 \ || timeout 10 warp-cli disconnect >/dev/null 2>&1 \ || true fi systemctl stop warp-svc.service >/dev/null 2>&1 || true local i for i in {1..10}; do systemctl is-active --quiet warp-svc.service || break sleep 1 done if systemctl is-active --quiet warp-svc.service; then systemctl kill --kill-who=all --signal=KILL warp-svc.service >/dev/null 2>&1 || true systemctl stop warp-svc.service >/dev/null 2>&1 || true fi } mask_warp_service() { stop_warp_safely systemctl unmask warp-svc.service >/dev/null 2>&1 || true systemctl mask --runtime warp-svc.service >/dev/null \ || fatal "warp-svc gagal di-mask sementara." WARP_MASKED_BY_SCRIPT=1 } unmask_warp_service() { systemctl unmask --runtime warp-svc.service >/dev/null 2>&1 || true systemctl unmask warp-svc.service >/dev/null 2>&1 || true WARP_MASKED_BY_SCRIPT=0 } remove_route_and_address() { local iface="$1" local address="$2" local prefix="$3" local gateway="$4" local metric="$5" [[ -n "$iface" ]] || return 0 validate_iface "$iface" || return 0 ip link show dev "$iface" >/dev/null 2>&1 || return 0 if [[ -n "$gateway" ]] && validate_ipv4 "$gateway"; then ip -4 route del default via "$gateway" dev "$iface" metric "${metric:-65000}" >/dev/null 2>&1 \ || ip -4 route del default via "$gateway" dev "$iface" >/dev/null 2>&1 \ || true fi if [[ -n "$address" && -n "$prefix" ]] \ && validate_ipv4 "$address" \ && [[ "$prefix" =~ ^[0-9]+$ ]]; then ip -4 addr del "${address}/${prefix}" dev "$iface" >/dev/null 2>&1 || true fi } cleanup_old_rollback_units() { systemctl stop \ "${LEGACY_ROLLBACK_UNIT}.timer" \ "${LEGACY_ROLLBACK_UNIT}.service" \ >/dev/null 2>&1 || true systemctl reset-failed \ "${LEGACY_ROLLBACK_UNIT}.timer" \ "${LEGACY_ROLLBACK_UNIT}.service" \ >/dev/null 2>&1 || true rm -f "$ROLLBACK_SCRIPT" } remove_managed_artifacts() { systemctl disable --now "$BOOTSTRAP_SERVICE" >/dev/null 2>&1 || true remove_route_and_address \ "$IFACE" "$DUMMY_IPV4" "$DUMMY_PREFIX" "$DUMMY_GATEWAY" "$DUMMY_METRIC" if [[ -n "$OLD_IFACE" ]]; then remove_route_and_address \ "$OLD_IFACE" "$OLD_DUMMY_IPV4" "$OLD_DUMMY_PREFIX" \ "$OLD_DUMMY_GATEWAY" "$OLD_DUMMY_METRIC" fi rm -f "$BOOTSTRAP_UNIT" "$BOOTSTRAP_HELPER" "$WARP_DROPIN_FILE" "$LEGACY_DROPIN_FILE" rmdir "$WARP_DROPIN_DIR" >/dev/null 2>&1 || true rm -rf "$STATE_DIR" ip link delete CloudflareWARP >/dev/null 2>&1 || true rm -rf /run/cloudflare-warp systemctl daemon-reload systemctl reset-failed warp-svc.service "$BOOTSTRAP_SERVICE" >/dev/null 2>&1 || true } fresh_cleanup() { log "Tahap 3/6: membersihkan instalasi lama agar benar-benar fresh" backup_existing_state cleanup_old_rollback_units mask_warp_service remove_managed_artifacts if dpkg-query -W -f='${Status}' cloudflare-warp 2>/dev/null \ | grep -q '^install ok installed$'; then apt-get "${APT_OPTS[@]}" purge -y cloudflare-warp \ || fatal "Paket cloudflare-warp lama gagal di-purge." fi rm -rf /etc/cloudflare-warp /var/lib/cloudflare-warp /var/log/cloudflare-warp /run/cloudflare-warp systemctl daemon-reload systemctl reset-failed warp-svc.service >/dev/null 2>&1 || true if dpkg-query -W -f='${Status}' cloudflare-warp 2>/dev/null \ | grep -q '^install ok installed$'; then fatal "Paket cloudflare-warp masih terpasang setelah purge." fi [[ ! -e /var/lib/cloudflare-warp ]] \ || fatal "State lama /var/lib/cloudflare-warp belum bersih." ok "Paket, registrasi, runtime, unit custom, route dummy, dan state lama sudah dibersihkan." } prepare_nonfresh_upgrade() { log "Tahap 3/6: menyiapkan upgrade tanpa menghapus registrasi WARP" backup_existing_state cleanup_old_rollback_units mask_warp_service remove_managed_artifacts ok "Registrasi/state WARP dipertahankan; unit dan route bootstrap lama dibersihkan." } install_warp_package() { log "Tahap 4/6: memasang paket Cloudflare WARP dengan daemon tetap ditahan" export DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" install -y --no-install-recommends cloudflare-warp \ || fatal "Instalasi paket cloudflare-warp gagal." dpkg-query -W -f='${Status}' cloudflare-warp 2>/dev/null \ | grep -q '^install ok installed$' \ || fatal "dpkg tidak menandai cloudflare-warp sebagai paket terpasang." command -v warp-cli >/dev/null 2>&1 || fatal "warp-cli tidak ditemukan setelah instalasi." command -v warp-svc >/dev/null 2>&1 || fatal "warp-svc tidak ditemukan setelah instalasi." unmask_warp_service systemctl daemon-reload systemctl cat warp-svc.service >/dev/null 2>&1 \ || fatal "Unit warp-svc.service tidak ditemukan setelah instalasi." init_warp_cli check_cli_capabilities ok "Paket terpasang: $(warp-cli --version 2>/dev/null || echo unknown)" } init_warp_cli() { if warp-cli --help 2>&1 | grep -q -- '--accept-tos'; then WARP_ACCEPT_TOS=1 else WARP_ACCEPT_TOS=0 fi } warp_cli() { if (( WARP_ACCEPT_TOS == 1 )); then command warp-cli --accept-tos "$@" else command warp-cli "$@" fi } warp_cli_timeout() { local duration="$1" shift if (( WARP_ACCEPT_TOS == 1 )); then timeout "$duration" warp-cli --accept-tos "$@" else timeout "$duration" warp-cli "$@" fi } check_cli_capabilities() { local help_text help_text="$(warp-cli registration --help 2>&1 || true)" grep -Eq '(^|[[:space:]])new([[:space:]]|$)' <<<"$help_text" \ || fatal "Versi warp-cli tidak menyediakan 'registration new'." help_text="$(warp-cli tunnel --help 2>&1 || true)" grep -Eq '(^|[[:space:]])protocol([[:space:]]|$)' <<<"$help_text" \ || fatal "Versi warp-cli tidak menyediakan pengaturan tunnel protocol." grep -Eq '(^|[[:space:]])ip([[:space:]]|$)' <<<"$help_text" \ || fatal "Versi warp-cli tidak menyediakan pengaturan split tunnel IP." help_text="$(warp-cli tunnel ip --help 2>&1 || true)" grep -q 'add-range' <<<"$help_text" \ || fatal "Versi warp-cli tidak menyediakan 'tunnel ip add-range'." ok "Kapabilitas CLI yang dibutuhkan tersedia." } arm_rollback() { cat >"$ROLLBACK_SCRIPT" </dev/null 2>&1 || timeout 8 warp-cli disconnect >/dev/null 2>&1 systemctl disable --now warp-svc.service >/dev/null 2>&1 systemctl disable --now ${BOOTSTRAP_SERVICE} >/dev/null 2>&1 ${IP_BIN} -4 route del default via ${DUMMY_GATEWAY} dev ${IFACE} metric ${DUMMY_METRIC} >/dev/null 2>&1 ${IP_BIN} -4 route del default via ${DUMMY_GATEWAY} dev ${IFACE} >/dev/null 2>&1 ${IP_BIN} -4 addr del ${DUMMY_IPV4}/${DUMMY_PREFIX} dev ${IFACE} >/dev/null 2>&1 rm -f ${BOOTSTRAP_UNIT} ${BOOTSTRAP_HELPER} ${WARP_DROPIN_FILE} rmdir ${WARP_DROPIN_DIR} >/dev/null 2>&1 rm -rf ${STATE_DIR} /run/cloudflare-warp systemctl daemon-reload >/dev/null 2>&1 systemctl restart ssh.service >/dev/null 2>&1 || systemctl restart sshd.service >/dev/null 2>&1 EOF_ROLLBACK chmod 0700 "$ROLLBACK_SCRIPT" systemd-run \ --quiet \ --unit="$ROLLBACK_UNIT" \ --on-active=10m \ --timer-property=AccuracySec=1s \ /bin/bash "$ROLLBACK_SCRIPT" \ || fatal "Timer rollback keselamatan gagal dibuat." ROLLBACK_ARMED=1 warn "Rollback keselamatan aktif selama 10 menit. Pertahankan console/noVNC sampai verifikasi selesai." } write_bootstrap_service() { log "Tahap 5/6: membuat bootstrap IPv4 dan memulai daemon secara terurut" install -d -m 0755 "$(dirname "$BOOTSTRAP_HELPER")" cat >"$BOOTSTRAP_HELPER" </dev/null 2>&1 \ || ${IP_BIN} -4 route del default via ${DUMMY_GATEWAY} dev ${IFACE} >/dev/null 2>&1 \ || true ${IP_BIN} -4 addr del ${DUMMY_IPV4}/${DUMMY_PREFIX} dev ${IFACE} >/dev/null 2>&1 || true ;; *) echo "usage: \$0 {start|stop}" >&2 exit 2 ;; esac EOF_HELPER chmod 0755 "$BOOTSTRAP_HELPER" cat >"$BOOTSTRAP_UNIT" <"$WARP_DROPIN_FILE" <"$STATE_FILE" </dev/null systemctl restart "$BOOTSTRAP_SERVICE" ip -4 addr show dev "$IFACE" \ | grep -Fq "inet ${DUMMY_IPV4}/${DUMMY_PREFIX}" \ || fatal "IPv4 dummy gagal dipasang pada ${IFACE}." ip -4 route show default \ | grep -Fq "default via ${DUMMY_GATEWAY} dev ${IFACE}" \ || fatal "Default route IPv4 dummy gagal dibuat." verify_native_ipv6_route ok "IPv4 dummy aktif: ${DUMMY_IPV4}/${DUMMY_PREFIX}, gateway ${DUMMY_GATEWAY}, metric ${DUMMY_METRIC}." } verify_native_ipv6_route() { local route6 route6="$(ip -6 route get 2606:4700:4700::1111 2>/dev/null || true)" [[ -n "$route6" ]] || fatal "Route IPv6 hilang setelah perubahan IPv4." grep -Eq "dev[[:space:]]+${IFACE}([[:space:]]|$)" <<<"$route6" \ || fatal "IPv6 tidak lagi melalui ${IFACE}: ${route6}" } ipc_output_is_usable() { local output="$1" [[ -n "$output" ]] || return 1 if grep -Eqi \ 'unable to connect.*daemon|daemon.*not running|connection refused|no such file or directory|ipc.*(failed|error)' \ <<<"$output"; then return 1 fi return 0 } start_daemon_and_wait_for_ipc() { local i output rc restarted=0 rm -rf /run/cloudflare-warp install -d -m 0755 -o root -g root /run/cloudflare-warp systemctl reset-failed warp-svc.service >/dev/null 2>&1 || true systemctl start warp-svc.service \ || fatal "warp-svc.service gagal dimulai." for i in {1..60}; do output="" rc=0 set +e output="$(warp_cli_timeout 6 status 2>&1)" rc=$? set -e if systemctl is-active --quiet warp-svc.service \ && ((rc != 124 && rc != 137)) \ && ipc_output_is_usable "$output"; then ok "Daemon WARP aktif dan IPC/CLI merespons." return 0 fi if ((i == 30 && restarted == 0)); then warn "IPC belum siap setelah 30 detik; membersihkan runtime lalu restart daemon satu kali." stop_warp_safely rm -rf /run/cloudflare-warp install -d -m 0755 -o root -g root /run/cloudflare-warp systemctl start warp-svc.service || true restarted=1 fi sleep 1 done printf '%s\n' "${output:-warp-cli tidak menghasilkan output}" >&2 fatal "Daemon WARP aktif tetapi IPC/CLI tidak siap setelah 60 detik." } registration_is_present() { local output rc output="" rc=0 set +e output="$(warp_cli_timeout 20 registration show 2>&1)" rc=$? set -e ((rc == 0)) || return 1 grep -Eqi 'not registered|registration.*(missing|not found)|no registration|error:' <<<"$output" \ && return 1 [[ -n "$output" ]] } create_registration_once() { if (( WARP_ACCEPT_TOS == 1 )); then warp_cli_timeout 90 registration new else printf 'y\n' | timeout 90 warp-cli registration new fi } ensure_registration() { local attempt rc output if registration_is_present; then ok "Registrasi WARP sudah tersedia." return 0 fi warn "Registrasi WARP belum tersedia; membuat registrasi consumer baru." for attempt in 1 2 3; do output="" rc=0 set +e output="$(create_registration_once 2>&1)" rc=$? set -e [[ -n "$output" ]] && printf '%s\n' "$output" if registration_is_present; then ok "Registrasi WARP berhasil dibuat pada percobaan ${attempt}." return 0 fi if ((attempt < 3)); then warn "Registrasi belum tervalidasi (rc=${rc}); restart daemon dan mencoba lagi." stop_warp_safely rm -rf /run/cloudflare-warp install -d -m 0755 -o root -g root /run/cloudflare-warp systemctl start warp-svc.service || true start_daemon_and_wait_for_ipc fi done fatal "Registrasi WARP gagal setelah 3 percobaan. Rilis Linux terbaru memiliki known issue IPC; reboot server lalu jalankan ulang script dapat diperlukan." } configure_warp() { local ip_list warp_cli_timeout 20 mode warp \ || fatal "Gagal mengatur WARP ke traffic-only mode." warp_cli_timeout 20 tunnel protocol set MASQUE \ || fatal "Gagal mengatur protokol MASQUE." ip_list="$(warp_cli_timeout 20 tunnel ip list 2>&1 || true)" if grep -Eqi 'include mode|included routes' <<<"$ip_list"; then fatal "Split Tunnel terdeteksi dalam Include mode; script tidak akan mengubahnya karena berisiko mengalihkan IPv6." fi if ! grep -Eq '(^|[[:space:]])::/0([[:space:]]|$)' <<<"$ip_list"; then warp_cli_timeout 20 tunnel ip add-range ::/0 \ || fatal "Gagal menambahkan ::/0 ke Split Tunnel Exclude." fi ip_list="$(warp_cli_timeout 20 tunnel ip list 2>&1 || true)" grep -Eq '(^|[[:space:]])::/0([[:space:]]|$)' <<<"$ip_list" \ || fatal "::/0 tidak ditemukan setelah konfigurasi Split Tunnel." verify_native_ipv6_route ok "WARP disetel ke traffic-only/MASQUE dan semua IPv6 (::/0) dikecualikan." } status_is_connected() { local output="$1" grep -Eqi 'disconnected|not[[:space:]]+connected|unable|failed|error' <<<"$output" \ && return 1 grep -Eqi '(^|[^[:alpha:]])Connected([^[:alpha:]]|$)' <<<"$output" } wait_until_connected() { local i status rc set +e warp_cli_timeout 25 connect rc=$? set -e if ((rc != 0)); then warn "Perintah connect mengembalikan rc=${rc}; status akan tetap dipantau." fi status="" for i in {1..90}; do set +e status="$(warp_cli_timeout 8 status 2>&1)" rc=$? set -e if ((rc != 124 && rc != 137)) && status_is_connected "$status"; then printf '%s\n' "$status" ok "WARP tersambung." return 0 fi sleep 1 done printf '%s\n' "${status:-warp-cli status kosong}" >&2 fatal "WARP tidak mencapai status Connected dalam 90 detik." } verify_routes_and_connectivity() { local trace4 trace6 route6 service_enabled bootstrap_enabled trace4="$( timeout 35 curl -4 -fsS \ --connect-timeout 12 --max-time 30 \ https://www.cloudflare.com/cdn-cgi/trace )" || fatal "Tes HTTPS IPv4 melalui WARP gagal." grep -q '^warp=on$' <<<"$trace4" \ || fatal "IPv4 berhasil keluar tetapi trace tidak menunjukkan warp=on." route6="$(ip -6 route get 2606:4700:4700::1111 2>/dev/null || true)" grep -Eq "dev[[:space:]]+${IFACE}([[:space:]]|$)" <<<"$route6" \ || fatal "Route IPv6 tidak lagi melalui ${IFACE}." trace6="$( timeout 35 curl -6 -fsS \ --connect-timeout 12 --max-time 30 \ https://www.cloudflare.com/cdn-cgi/trace )" || fatal "Tes HTTPS IPv6 native gagal setelah WARP aktif." grep -q '^warp=off$' <<<"$trace6" \ || fatal "Trace IPv6 tidak menunjukkan warp=off; pengecualian ::/0 belum aman." systemctl is-active --quiet warp-svc.service \ || fatal "warp-svc tidak aktif setelah verifikasi." systemctl is-active --quiet "$BOOTSTRAP_SERVICE" \ || fatal "Bootstrap IPv4 tidak aktif setelah verifikasi." service_enabled="$(systemctl is-enabled warp-svc.service 2>/dev/null || true)" bootstrap_enabled="$(systemctl is-enabled "$BOOTSTRAP_SERVICE" 2>/dev/null || true)" [[ "$service_enabled" == "enabled" ]] \ || fatal "warp-svc belum enabled untuk boot." [[ "$bootstrap_enabled" == "enabled" ]] \ || fatal "Bootstrap IPv4 belum enabled untuk boot." printf '\n--- IPv4 trace ---\n' grep -E '^(ip|colo|loc|warp)=' <<<"$trace4" printf '\n--- IPv6 trace ---\n' grep -E '^(ip|colo|loc|warp)=' <<<"$trace6" printf '\n--- IPv6 route ---\n%s\n' "$route6" ok "IPv4 keluar melalui WARP; IPv6 dan SSH tetap pada interface native ${IFACE}." } disarm_rollback() { systemctl stop "${ROLLBACK_UNIT}.timer" "${ROLLBACK_UNIT}.service" >/dev/null 2>&1 || true systemctl reset-failed "${ROLLBACK_UNIT}.timer" "${ROLLBACK_UNIT}.service" >/dev/null 2>&1 || true rm -f "$ROLLBACK_SCRIPT" ROLLBACK_ARMED=0 } print_preflight_summary() { cat </dev/null \ | awk '!found {print $4; found=1}' )" package_version="$(dpkg-query -W -f='${Version}' cloudflare-warp 2>/dev/null || echo unknown)" cat <