Files
PCA_Phobos/server/phobos-warp.sh
Бобырев Андрей 7eac63343d release: 1.4.0 — one version for stable/beta/dev
Light/dark theme and compact mobile layout, per-client speed limit, real
speed tests, panel domain (Let's Encrypt), client address + cascade relay,
RKN scanner guard, WARP, backup/restore, router web terminal, Android VPN
client with split tunnelling and Android admin app. Updating from 1.2.x
keeps keys, server.env and clients; tested 1.2.8 -> 1.4.0 on live VMs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:22:46 +03:00

520 lines
16 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
SERVER_ENV="/opt/Phobos/server/server.env"
WARP_DIR="/opt/Phobos/server/warp"
WGCF_BIN="/usr/local/bin/wgcf"
WARP_IF="warp"
WARP_CONF="/etc/wireguard/${WARP_IF}.conf"
WARP_TABLE="51820"
WARP_RULE_PRIO="11930"
STATE_FILE="${WARP_DIR}/state.env"
FREE_PROFILE="${WARP_DIR}/wgcf-profile.conf"
CUSTOM_PROFILE="${WARP_DIR}/custom-profile.conf"
LICENSE_FILE="${WARP_DIR}/license.key"
RELAY_TAG_PREFIX="PHOBOS_WARP_RELAY"
load_env() {
if [ -f "$SERVER_ENV" ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;;
esac
k="${line%%=*}"
v="${line#*=}"
case "$k" in
''|\#*) continue ;;
esac
export "$k=$v"
done < "$SERVER_ENV"
fi
}
load_state() {
STATE_MODE=""
STATE_PROFILE_SOURCE=""
STATE_RELAY_PORTS=""
STATE_RELAY_TARGET=""
STATE_RELAY_TARGET_IP=""
STATE_CLIENTS=""
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
. "$STATE_FILE"
STATE_MODE="${MODE:-}"
STATE_PROFILE_SOURCE="${PROFILE_SOURCE:-}"
STATE_RELAY_PORTS="${RELAY_PORTS:-}"
STATE_RELAY_TARGET="${RELAY_TARGET:-}"
STATE_RELAY_TARGET_IP="${RELAY_TARGET_IP:-}"
STATE_CLIENTS="${CLIENTS:-}"
fi
}
save_state() {
mkdir -p "$WARP_DIR"
cat > "$STATE_FILE" <<EOF
MODE=${1:-}
PROFILE_SOURCE=${2:-}
RELAY_PORTS=${3:-}
RELAY_TARGET=${4:-}
RELAY_TARGET_IP=${5:-}
CLIENTS=${6:-}
EOF
}
server_public_ip() {
if [ -n "${SERVER_PUBLIC_IP_V4:-}" ]; then
printf '%s\n' "$SERVER_PUBLIC_IP_V4"
else
curl -4 -fsS --max-time 6 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'
fi
}
wg_subnet() {
if [ -n "${SERVER_WG_IPV4_NETWORK:-}" ]; then
printf '%s\n' "$SERVER_WG_IPV4_NETWORK"
return
fi
if [ -f /etc/wireguard/wg0.conf ]; then
python3 - <<'PY' /etc/wireguard/wg0.conf
import ipaddress, re, sys
for raw in open(sys.argv[1], encoding="utf-8"):
m = re.match(r"^Address\s*=\s*(.+)$", raw.strip())
if not m:
continue
value = m.group(1).split(",", 1)[0].strip()
print(ipaddress.ip_interface(value).network)
break
PY
return
fi
printf '10.25.0.0/16\n'
}
arch_name() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l) echo "armv7" ;;
*) echo "" ;;
esac
}
ensure_wgcf() {
if [ -x "$WGCF_BIN" ]; then
return
fi
local arch version url
arch="$(arch_name)"
[ -n "$arch" ] || { echo "unsupported architecture: $(uname -m)"; exit 1; }
version="2.2.26"
url="https://github.com/ViRb3/wgcf/releases/download/v${version}/wgcf_${version}_linux_${arch}"
curl -fsSL "$url" -o "$WGCF_BIN"
chmod +x "$WGCF_BIN"
}
ensure_free_profile() {
local license_key=""
mkdir -p "$WARP_DIR"
cd "$WARP_DIR"
ensure_wgcf
if [ ! -f "${WARP_DIR}/wgcf-account.toml" ]; then
"$WGCF_BIN" register --accept-tos >/tmp/phobos-warp-register.log 2>&1
fi
if [ -s "$LICENSE_FILE" ]; then
license_key="$(tr -d '\r\n' < "$LICENSE_FILE")"
if [ -n "$license_key" ]; then
"$WGCF_BIN" update --license "$license_key" >/tmp/phobos-warp-license.log 2>&1 || {
echo "failed to apply WARP+ license key" >&2
cat /tmp/phobos-warp-license.log >&2 || true
exit 1
}
rm -f "$FREE_PROFILE"
fi
fi
if [ ! -f "$FREE_PROFILE" ] || [ ! -s "$FREE_PROFILE" ]; then
"$WGCF_BIN" generate >/tmp/phobos-warp-generate.log 2>&1 || {
echo "failed to generate wgcf profile" >&2
cat /tmp/phobos-warp-generate.log >&2 || true
exit 1
}
fi
}
ensure_custom_profile() {
[ -s "$CUSTOM_PROFILE" ] || { echo "custom profile not found: $CUSTOM_PROFILE" >&2; exit 1; }
}
profile_path_for() {
case "${1:-free}" in
custom) ensure_custom_profile; printf '%s\n' "$CUSTOM_PROFILE" ;;
*) ensure_free_profile; printf '%s\n' "$FREE_PROFILE" ;;
esac
}
profile_endpoint() {
awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$1" | tr -d ' '
}
resolve_host_ipv4() {
getent ahostsv4 "$1" | awk 'NR==1{print $1}'
}
build_warp_conf() {
local profile_path subnet
profile_path="$1"
subnet="$(wg_subnet)"
python3 - "$profile_path" "$WARP_CONF" "$subnet" "$WARP_TABLE" "$WARP_RULE_PRIO" <<'PY'
import sys
from pathlib import Path
profile_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
subnet = sys.argv[3]
table = sys.argv[4]
prio = sys.argv[5]
section = None
data = {"Interface": {}, "Peer": {}}
for raw in profile_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line.strip("[]")
continue
if "=" not in line or section not in data:
continue
k, v = [x.strip() for x in line.split("=", 1)]
data[section][k] = v
iface = data["Interface"]
peer = data["Peer"]
post_up = (
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
f"ip route replace {subnet} dev wg0 table {table}; "
f"ip route replace default dev %i table {table}; "
"iptables -C FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -i wg0 -o %i -j ACCEPT; "
"iptables -C FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || "
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
f"iptables -t nat -C POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || "
f"iptables -t nat -A POSTROUTING -s {subnet} -o %i -j MASQUERADE"
)
post_down = (
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
f"ip route del default dev %i table {table} 2>/dev/null || true; "
"iptables -D FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || true; "
"iptables -D FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
)
lines = [
"[Interface]",
f"PrivateKey = {iface.get('PrivateKey', '')}",
f"Address = {iface.get('Address', '')}",
"MTU = 1280",
"Table = off",
f"PostUp = {post_up}",
f"PostDown = {post_down}",
"",
"[Peer]",
f"PublicKey = {peer.get('PublicKey', '')}",
f"AllowedIPs = {peer.get('AllowedIPs', '0.0.0.0/0, ::/0')}",
f"Endpoint = {peer.get('Endpoint', '')}",
f"PersistentKeepalive = {peer.get('PersistentKeepalive', '25')}",
"",
]
out_path.write_text("\n".join(lines))
PY
chmod 600 "$WARP_CONF"
}
clear_egress() {
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
ip route del "$(wg_subnet)" dev wg0 table "$WARP_TABLE" 2>/dev/null || true
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
iptables -D FORWARD -i wg0 -o "$WARP_IF" -j ACCEPT 2>/dev/null || true
iptables -D FORWARD -i "$WARP_IF" -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
}
clear_legacy_subnet_rule() {
local subnet
subnet="$(wg_subnet)"
ip rule del from "$subnet" table "$WARP_TABLE" priority "$WARP_RULE_PRIO" 2>/dev/null || true
ip rule del from "$subnet" lookup "$WARP_TABLE" 2>/dev/null || true
}
client_rule_prio() {
python3 - "$1" "$WARP_RULE_PRIO" <<'PY'
import sys
ip = sys.argv[1].split('/')[0].strip()
base = int(sys.argv[2])
parts = [int(x) for x in ip.split('.')]
value = ((parts[2] << 8) + parts[3]) % 5000
print(base + 1 + value)
PY
}
normalize_client_ip() {
printf '%s\n' "${1%%/*}" | tr -d ' '
}
state_clients_csv_add() {
local ip csv
ip="$(normalize_client_ip "$1")"
csv="${2:-}"
python3 - "$ip" "$csv" <<'PY'
import sys
ip = sys.argv[1]
items = [x for x in sys.argv[2].split(',') if x]
if ip not in items:
items.append(ip)
print(",".join(items))
PY
}
state_clients_csv_del() {
local ip csv
ip="$(normalize_client_ip "$1")"
csv="${2:-}"
python3 - "$ip" "$csv" <<'PY'
import sys
ip = sys.argv[1]
items = [x for x in sys.argv[2].split(',') if x and x != ip]
print(",".join(items))
PY
}
clear_client_rules() {
load_state
local ip prio
IFS=',' read -r -a __clients <<< "${STATE_CLIENTS:-}"
for ip in "${__clients[@]}"; do
[ -n "$ip" ] || continue
prio="$(client_rule_prio "$ip")"
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
done
}
reapply_client_rules() {
local csv="${1:-}" ip prio
IFS=',' read -r -a __clients <<< "$csv"
for ip in "${__clients[@]}"; do
[ -n "$ip" ] || continue
prio="$(client_rule_prio "$ip")"
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
done
}
relay_ports_array() {
local raw="${1:-4500}"
raw="${raw// /}"
IFS=',' read -r -a RELAY_PORTS_ARRAY <<< "$raw"
}
relay_rule_tag() {
printf '%s:%s:%s:%s\n' "$RELAY_TAG_PREFIX" "$1" "$2" "$3"
}
clear_relay() {
load_state
local lines line cmd
lines="$(iptables-save -t nat 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
if [ -n "$lines" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
-A\ *) cmd="iptables -t nat ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
esac
done <<EOF
$lines
EOF
fi
lines="$(iptables-save 2>/dev/null | grep -F "$RELAY_TAG_PREFIX" || true)"
if [ -n "$lines" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
-A\ FORWARD\ *) cmd="iptables ${line/-A /-D }"; eval "$cmd" >/dev/null 2>&1 || true ;;
esac
done <<EOF
$lines
EOF
fi
}
disable_all() {
clear_legacy_subnet_rule
clear_client_rules
clear_egress
clear_relay
}
relay_enabled() {
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
}
enable_egress() {
local profile_source profile_path existing_clients
load_env
load_state
existing_clients="${STATE_CLIENTS:-}"
profile_source="${1:-free}"
disable_all
profile_path="$(profile_path_for "$profile_source")"
build_warp_conf "$profile_path"
sysctl -w net.ipv4.ip_forward=1 >/dev/null
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
systemctl restart "wg-quick@${WARP_IF}"
clear_legacy_subnet_rule
reapply_client_rules "$existing_clients"
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
status_json
}
enable_relay() {
local profile_source ports target profile_path endpoint host port_num target_ip server_ip port tag
load_env
profile_source="${1:-free}"
ports="${2:-4500}"
target="${3:-}"
disable_all
profile_path="$(profile_path_for "$profile_source")"
endpoint="${target:-$(profile_endpoint "$profile_path")}"
[ -n "$endpoint" ] || { echo "relay target endpoint is empty" >&2; exit 1; }
host="${endpoint%:*}"
port_num="${endpoint##*:}"
target_ip="$(resolve_host_ipv4 "$host")"
[ -n "$target_ip" ] || { echo "failed to resolve endpoint host: $host" >&2; exit 1; }
server_ip="$(server_public_ip)"
sysctl -w net.ipv4.ip_forward=1 >/dev/null
relay_ports_array "$ports"
for port in "${RELAY_PORTS_ARRAY[@]}"; do
[ -n "$port" ] || continue
tag="$(relay_rule_tag "$port" "$target_ip" "$port_num")"
iptables -t nat -C PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num" 2>/dev/null \
|| iptables -t nat -A PREROUTING -d "$server_ip" -p udp --dport "$port" -m comment --comment "$tag" -j DNAT --to-destination "$target_ip:$port_num"
iptables -t nat -C POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j MASQUERADE
iptables -C FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|| iptables -A FORWARD -p udp -d "$target_ip" --dport "$port_num" -m comment --comment "$tag" -j ACCEPT
iptables -C FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT 2>/dev/null \
|| iptables -A FORWARD -p udp -s "$target_ip" --sport "$port_num" -m comment --comment "$tag" -j ACCEPT
done
save_state "relay" "$profile_source" "$ports" "$endpoint" "$target_ip" ""
status_json
}
client_enable() {
local ip prio updated
ip="$(normalize_client_ip "${1:-}")"
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
load_state
[ "${STATE_MODE:-}" = "egress" ] || { echo "WARP egress mode is not active" >&2; exit 1; }
systemctl is-active --quiet "wg-quick@${WARP_IF}" || { echo "WARP interface is not active" >&2; exit 1; }
prio="$(client_rule_prio "$ip")"
ip rule add from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
updated="$(state_clients_csv_add "$ip" "${STATE_CLIENTS:-}")"
save_state "${STATE_MODE:-egress}" "${STATE_PROFILE_SOURCE:-free}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
status_json
}
client_disable() {
local ip prio updated
ip="$(normalize_client_ip "${1:-}")"
[ -n "$ip" ] || { echo "client ip required" >&2; exit 1; }
load_state
prio="$(client_rule_prio "$ip")"
ip rule del from "${ip}/32" table "$WARP_TABLE" priority "$prio" 2>/dev/null || true
updated="$(state_clients_csv_del "$ip" "${STATE_CLIENTS:-}")"
save_state "${STATE_MODE:-}" "${STATE_PROFILE_SOURCE:-}" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$updated"
status_json
}
status_json() {
load_env
load_state
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on
subnet="$(wg_subnet)"
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
mode="${STATE_MODE:-}"
profile_source="${STATE_PROFILE_SOURCE:-}"
configured=false
endpoint=""
pubkey=""
egress=""
relay_on=false
if [ "$service" = "active" ]; then
mode="${mode:-egress}"
configured=true
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || true)"
pubkey="$(wg show "${WARP_IF}" public-key 2>/dev/null || true)"
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
fi
if relay_enabled; then
relay_on=true
[ -n "$mode" ] || mode="relay"
configured=true
[ -n "$endpoint" ] || endpoint="${STATE_RELAY_TARGET:-}"
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
fi
enabled=false
if [ "$service" = "active" ] || [ "$relay_on" = true ]; then
enabled=true
fi
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" <<'PY'
import json, sys
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv = sys.argv[1:]
if relay_on == "true" and service != "active":
service = "relay"
print(json.dumps({
"enabled": enabled == "true",
"configured": configured == "true",
"service": service or "inactive",
"mode": mode or "off",
"profile_source": profile_source or "free",
"subnet": subnet,
"endpoint": endpoint,
"public_key": pubkey,
"egress_ipv4": egress,
"relay_enabled": relay_on == "true",
"relay_ports": relay_ports,
"relay_target": relay_target,
"relay_target_ip": relay_target_ip,
"clients": [x for x in clients_csv.split(",") if x],
"custom_profile_saved": custom_saved == "true",
"license_key_saved": license_saved == "true",
}, ensure_ascii=False))
PY
}
disable_warp() {
disable_all
save_state "" "" "" "" "" ""
status_json
}
case "${1:-status}" in
enable|up)
case "${2:-egress}" in
relay) enable_relay "${3:-free}" "${4:-4500}" "${5:-}" ;;
*) enable_egress "${3:-free}" ;;
esac
;;
client-enable) client_enable "${2:-}" ;;
client-disable) client_disable "${2:-}" ;;
disable|down) disable_warp ;;
status|check|--json) status_json ;;
*)
echo "usage: $0 {status|enable [egress|relay] [free|custom] [ports] [endpoint]|client-enable <ip>|client-disable <ip>|disable}" >&2
exit 1
;;
esac