release 1.5.0: one panel in all channels; Apple apps (iOS, new macOS tab) only in beta/dev

- stable gets the beta panel and server features (new design, presets, firewall,
  monthly traffic, router failover over all ports/addresses + TCP, altip, HR tab,
  Windows tab, update banner); ios/ macos/ windows/ sources stay out of stable
- menu item "Update to N" under Settings when a new version is out
- mobile: top menu no longer stretches to 200px
- urllib User-Agent: Cloudflare in front of git.bobyrev.com returns 403 to Python-urllib
- docs: GitHub adleibzon-rgb as source, password/uninstall/devices sections,
  new screenshots, PORTS.md TCP fallback and second IP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrei Leibzon
2026-11-11 18:12:13 +03:00
parent 22537b3aad
commit 1f3eced228
104 changed files with 2499 additions and 8256 deletions

View File

@@ -173,6 +173,48 @@ def speedtest():
return jsonify({"error": str(e)}), 500
# --- Google geo (09.30): how Google sees the server's addresses ---------------
GOOGLE_CLOSED = {"Russia", "Belarus", "China", "Iran", "Hong Kong", "North Korea", "Cuba", "Syria"}
def _google_country(*args):
"""«Country version» at the bottom of policies.google.com/terms = the country Google assigns to the address."""
import re as _re
import subprocess as _sp
try:
html = _sp.run(["curl", *args, "-s", "-m", "15", "https://policies.google.com/terms?hl=en"],
capture_output=True, text=True, timeout=25).stdout
m = _re.search(r"Country version:\s*</a>\s*([A-Za-z][A-Za-z .'-]*?)\s*<", html)
return m.group(1) if m else None
except Exception:
return None
def google_geo():
"""v4/v6 = default route (what clients exit with); ips = every public IPv4 on the server."""
import re as _re
import subprocess as _sp
import time as _t
out = {"v4": _google_country("-4"), "v6": _google_country("-6"), "ips": {}, "ts": int(_t.time())}
try:
for line in _sp.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True,
text=True, timeout=10).stdout.splitlines():
ip = line.split()[3].split("/")[0]
if not _re.match(r"^(10|127|172\.(1[6-9]|2\d|3[01])|192\.168|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7]))\.", ip):
out["ips"][ip] = _google_country("-4", "--interface", ip)
except Exception:
pass
return out
@app.route("/api/google-geo")
def api_google_geo():
if not check_api_key():
return jsonify({"error": "unauthorized"}), 401
return jsonify(google_geo())
@app.route("/api/limits", methods=["POST"])
def set_limits():
"""Per-client speed limits pushed by the main panel ("<tunnel_ip> <mbit>" lines)."""

43
server/phobos-altip.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
# PCA Phobos — второй/запасной IP сервера (09.33).
# wg-obfuscator и udptcp слушают 0.0.0.0, но отвечают с основного IP, а провайдер
# клиента может резать именно основной. DNAT «второй IP:порт → основной IP:порт»
# заставляет ответ уходить с того адреса, на который пришёл запрос.
# phobos-altip apply — правила для всех доп. IPv4 сервера (служба делает это при старте)
# phobos-altip status — что настроено
# phobos-altip enable — поставить службу phobos-altip
ENV=/opt/Phobos/server/server.env
[ -f "$ENV" ] || ENV=/opt/Phobos/server.env
get() { grep -E "^$1=" "$ENV" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"; }
MAIN=$(get SERVER_PUBLIC_IP_V4)
PORTS=$(get OBFUSCATOR_PORTS); [ -n "$PORTS" ] || PORTS=2083,5443,993
TCP=$(get TCP_PORT)
alts() { ip -4 -o addr show scope global | awk '{split($4,a,"/"); print a[1]}' | grep -vxF "$MAIN" | grep -v '^10\.\|^172\.\(1[6-9]\|2[0-9]\|3[01]\)\.\|^192\.168\.'; }
rule() { # $1 proto $2 alt $3 port
iptables -t nat -C PREROUTING -d "$2" -p "$1" --dport "$3" -j DNAT --to-destination "$MAIN:$3" 2>/dev/null \
|| iptables -t nat -I PREROUTING -d "$2" -p "$1" --dport "$3" -j DNAT --to-destination "$MAIN:$3"
}
case "$1" in
apply)
[ -n "$MAIN" ] || { echo "SERVER_PUBLIC_IP_V4 not set"; exit 1; }
for a in $(alts); do
for p in $(echo "$PORTS" | tr ',' ' '); do rule udp "$a" "$p"; done
[ -n "$TCP" ] && rule tcp "$a" "$TCP"
echo "alt $a -> $MAIN (udp $PORTS${TCP:+, tcp $TCP})"
done ;;
status) echo "main: $MAIN"; echo "alt: $(alts | tr '\n' ' ')"; iptables -t nat -S PREROUTING | grep -- "--to-destination $MAIN:" ;;
enable)
cat > /etc/systemd/system/phobos-altip.service <<UNIT
[Unit]
Description=PCA Phobos: second IP answers from itself (DNAT)
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh $(readlink -f "$0") apply
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload && systemctl enable --now phobos-altip ;;
*) echo "usage: $0 apply|status|enable"; exit 1 ;;
esac

View File

@@ -6,11 +6,19 @@
# ============================================================
PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf"
STATE="$PHOBOS_DIR/state"
LOG="$PHOBOS_DIR/health.log"
# 02.11.2026: несколько учёток на одном роутере. Файлы учётки (failover.conf, state,
# client_id, panel_*) — в PHOBOS_ACC (доп. учётки: $PHOBOS_DIR/acc/<клиент>),
# главная — прямо в $PHOBOS_DIR. Конфиги обфускаторов общие, свой ищем по *.link.
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
STATE="$ACC_DIR/state"
LOG="$ACC_DIR/health.log"
CID=$(cat "$ACC_DIR/client_id" 2>/dev/null)
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
LOCKFILE="/tmp/phobos-health.lock"
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ -n "$CID" ] && [ "$(cat "$l" 2>/dev/null)" = "$CID" ] && OBF_CONF="${l%.link}.conf" && break
done
LOCKFILE="/tmp/phobos-health${PHOBOS_ACC:+-$CID}.lock"
WG_IF="Wireguard3"
MAX_LOG_LINES=200
@@ -25,9 +33,9 @@ PRIMARY_CHECK_INTERVAL=300
# wg-obfuscator-* services are active — the bare panel port stays up even
# when the tunnel path is dead, so we must NOT switch back on panel liveness
# alone (that caused premature switchback to a dead primary).
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$PHOBOS_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-$(cat "$ACC_DIR/panel_scheme" 2>/dev/null)}"
PRIMARY_PROBE_SCHEME="${PRIMARY_PROBE_SCHEME:-http}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$ACC_DIR/panel_port" 2>/dev/null)}"
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-8443}"
PRIMARY_PROBE_PATH="/api/obf-health"
# Connectivity check targets (logged for context only)
@@ -55,6 +63,16 @@ mkdir -p "$STATE"
# ── Detect WG interface ──
detect_wg_interface() {
# точное совпадение "Phobos-<клиент>"; доп. учётка не должна схватить чужой интерфейс
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
# HydraRoute Neo names the interface by the bare client id (09.33)
if [ -n "$CID" ] && echo "$desc" | grep -qE "(Phobos-|: )${CID}\$"; then
WG_IF="Wireguard${i}"
return 0
fi
done
[ -n "$PHOBOS_ACC" ] && return 1
for i in 0 1 2 3 4 5 6 7 8 9; do
desc=$(ndmc -c "show interface Wireguard${i}" 2>/dev/null | grep description | head -1)
if echo "$desc" | grep -qi phobos; then
@@ -65,6 +83,19 @@ detect_wg_interface() {
return 1
}
# ── Restart own obfuscator (init script whose ARGS name our config) ──
restart_obf() {
sc=$(grep -l "/$(basename "$OBF_CONF")\"\$" /opt/etc/init.d/S*obfuscator* 2>/dev/null | head -1)
[ -z "$sc" ] && [ -f /opt/etc/init.d/S49wg-obfuscator ] && sc=/opt/etc/init.d/S49wg-obfuscator
if [ -n "$sc" ]; then
"$sc" restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
}
# ── Read failover config ──
if [ ! -f "$CONF" ]; then
log "ERROR: no failover.conf"
@@ -80,6 +111,7 @@ while true; do
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_WGKEY=$(grep "^WGKEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
eval "SERVER_${idx}_TCP=$(grep "^TCP_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
SERVER_COUNT=$idx
idx=$((idx + 1))
done
@@ -94,14 +126,26 @@ CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
TCP_MODE=$(cat "$STATE/tcp_mode" 2>/dev/null || echo "0") # 0 = UDP, N = TCP via server N
CYCLES=$(cat "$STATE/udp_cycles" 2>/dev/null || echo "0") # full UDP rounds without success
UDPTCP_BIN=/opt/bin/udptcp
UDPTCP_LPORT=13256
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
# ── Detect Phobos WG interface ──
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим (так сторож
# удалённой доп. учётки вписал пир Phobos в посторонний туннель).
detect_wg_interface || {
log "ERROR: own WG interface not found, skip"
exit 0
}
# ── Panel tunnel IP must go through our tunnel (HydraRoute-made interface has no
# such route → pull went to WAN and hung, 09.33) ──
PT_IP=$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)
if [ -n "$PT_IP" ] && ip route get "$PT_IP" 2>/dev/null | grep -q "dev ppp\|dev eth\|dev wan"; then
ndmc -c "ip route $PT_IP 255.255.255.255 $WG_IF auto" >/dev/null 2>&1 && log "ROUTE: $PT_IP via $WG_IF"
fi
# ── Get handshake age via ndmc ──
# A peer switch can leave the removed peer's stale handshake listed in ndmc
# output (often a huge sentinel like 2147483647). Take the FRESHEST (minimum
@@ -151,9 +195,13 @@ list_iface_keys() {
count_server_peers() {
present=$(list_iface_keys)
n=0
seen=" "
i=1
while [ "$i" -le "$SERVER_COUNT" ]; do
eval "wk=\$SERVER_${i}_WGKEY"
# one server may have several addresses = same key in several entries
case "$seen" in *" $wk "*) i=$((i + 1)); continue ;; esac
seen="$seen$wk "
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
n=$((n + 1))
fi
@@ -194,7 +242,7 @@ switch_wg_peer() {
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
# Через ndmc по шагам: JSON-RCI с allow-ips "::"/0 молча не ставил allow-ips.
lport=$(grep 'source-lport' "$PHOBOS_DIR/wg-obfuscator.conf" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
lport=$(grep 'source-lport' "$OBF_CONF" 2>/dev/null | tr -d ' ' | cut -d'=' -f2)
lport=${lport:-13255}
ndmc -c "interface $WG_IF wireguard peer $new_key" >/dev/null 2>&1
ndmc -c "interface $WG_IF wireguard peer $new_key endpoint 127.0.0.1:$lport" >/dev/null 2>&1
@@ -226,6 +274,7 @@ switch_endpoint() {
fi
log "SWITCH → server $server_idx ($host:$port)"
stop_tcp
# 1. Switch WG peer key if different server
switch_wg_peer "$wg_key"
@@ -239,13 +288,7 @@ switch_endpoint() {
fi
# 3. Restart obfuscator
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
restart_obf
# 4. Save state (fail_count is managed by the caller, NOT reset here —
# resetting on a port-hop would prevent escalation to server failover)
@@ -253,6 +296,45 @@ switch_endpoint() {
echo "$port_idx" > "$STATE/current_port_idx"
}
# ── TCP channel (09.33/1.5.0): obfuscated UDP inside TCP via local udptcp ──
stop_tcp() {
[ "$TCP_MODE" = "0" ] && ! pidof udptcp >/dev/null 2>&1 && return 0
killall udptcp 2>/dev/null
TCP_MODE=0
echo 0 > "$STATE/tcp_mode"
}
# start_tcp N — tunnel through server N's TCP port; 1 = started
start_tcp() {
n=$1
eval "host=\$SERVER_${n}_HOST"
eval "tport=\$SERVER_${n}_TCP"
eval "obf_key=\$SERVER_${n}_KEY"
eval "wg_key=\$SERVER_${n}_WGKEY"
[ -n "$tport" ] && [ -x "$UDPTCP_BIN" ] || return 1
killall udptcp 2>/dev/null
"$UDPTCP_BIN" -c "127.0.0.1:${UDPTCP_LPORT}" "${host}:${tport}" >/dev/null 2>&1 &
switch_wg_peer "$wg_key"
sed -i "s|^target = .*|target = 127.0.0.1:${UDPTCP_LPORT}|" "$OBF_CONF"
[ -n "$obf_key" ] && sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
restart_obf
TCP_MODE=$n
echo "$n" > "$STATE/tcp_mode"
echo "$n" > "$STATE/current_server"
log "TCP MODE → server $n (${host}:${tport} over TCP)"
return 0
}
# try_tcp_after N — first server after N (no wrap) that has a TCP port; 1 = none
try_tcp_after() {
i=$(( $1 + 1 ))
while [ "$i" -le "$SERVER_COUNT" ]; do
start_tcp "$i" && return 0
i=$((i + 1))
done
return 1
}
# ── Try next port on current server ──
try_next_port() {
port_count=$(get_port_count "$CURRENT_SERVER")
@@ -267,7 +349,9 @@ try_next_port() {
# ── Try next server ──
try_next_server() {
next=$((CURRENT_SERVER + 1))
[ "$next" -gt "$SERVER_COUNT" ] && next=1
if [ "$next" -gt "$SERVER_COUNT" ]; then
next=1
fi
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
log "FAILOVER → server $next"
@@ -277,7 +361,7 @@ try_next_server() {
# ── Check if primary is back ──
check_primary() {
[ "$CURRENT_SERVER" -eq 1 ] && return
[ "$CURRENT_SERVER" -eq 1 ] && [ "$TCP_MODE" = "0" ] && return
now=$(date +%s)
elapsed=$((now - PRIMARY_CHECK_TS))
@@ -299,7 +383,10 @@ check_primary() {
fi
done
if [ -n "$ok_url" ]; then
log "PRIMARY ($ok_url http=200) alive, switching back"
# HTTP alive does not mean UDP passes (провайдер режет UDP к IP, 09.33):
# пробуем, а если рукопожатия не будет — trial_check вернёт как было.
log "PRIMARY ($ok_url http=200) alive, TRIAL switch back"
echo "$CURRENT_SERVER $CURRENT_PORT_IDX $TCP_MODE $(date +%s)" > "$STATE/trial"
switch_endpoint 1 0
echo "0" > "$STATE/fail_count"
else
@@ -341,6 +428,8 @@ heal_lan_routing() {
while read -r mac pol; do
[ -z "$mac" ] && continue
case "$mac" in \#*) continue;; esac
# политику удалили — не воскрешаем привязку к ней
echo "$rc" | grep -qx "ip policy $pol" || continue
if ! echo "$rc" | grep -q "host $mac policy $pol"; then
ndmc -c "ip hotspot host $mac policy $pol" >/dev/null 2>&1
log "HEAL: re-bound host $mac -> $pol"
@@ -417,12 +506,31 @@ fi
AGE=$(get_handshake_age)
WAN=$(check_connectivity && echo "yes" || echo "no")
# ── Trial of the primary: fresh handshake within ~2 min or go back ──
if [ -f "$STATE/trial" ]; then
read t_srv t_port t_tcp t_ts < "$STATE/trial"
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
log "TRIAL ok: primary works"
rm -f "$STATE/trial"; echo 0 > "$STATE/udp_cycles"
elif [ $(( $(date +%s) - ${t_ts:-0} )) -ge 110 ]; then
rm -f "$STATE/trial"
log "TRIAL failed: back to server $t_srv"
if [ "${t_tcp:-0}" != "0" ]; then start_tcp "$t_tcp" || switch_endpoint "$t_srv" "$t_port"
else switch_endpoint "$t_srv" "$t_port"; fi
echo 0 > "$STATE/fail_count"
exit 0
else
exit 0 # give the trial time
fi
fi
# Tunnel healthy = fresh handshake
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
if [ "$FAIL_COUNT" -gt 0 ]; then
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
echo "0" > "$STATE/fail_count"
fi
echo 0 > "$STATE/udp_cycles"
check_primary
exit 0
fi
@@ -438,23 +546,30 @@ log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PO
# Stage 1 (fail 1): Restart obfuscator
if [ "$FAIL_COUNT" -eq 1 ]; then
log "ACTION: restart obfuscator"
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
else
killall wg-obfuscator 2>/dev/null
sleep 1
wg-obfuscator --config "$OBF_CONF" &
fi
restart_obf
exit 0
fi
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
if [ "$FAIL_COUNT" -eq 2 ]; then
# TCP mode went stale → back to the UDP round (next server)
if [ "$TCP_MODE" != "0" ] && [ "$FAIL_COUNT" -ge 2 ]; then
log "TCP stale on server $TCP_MODE"
if try_tcp_after "$TCP_MODE"; then echo 0 > "$STATE/fail_count"; exit 0; fi
stop_tcp
switch_endpoint 1 0; echo 0 > "$STATE/fail_count"; exit 0
fi
# Stage 2 (fail 2+): hop through ALL ports of the SAME server (was: only one)
if [ "$FAIL_COUNT" -ge 2 ]; then
if try_next_port; then
exit 0
fi
# only one port → fall through to server failover
log "single port, escalate to failover"
log "ports of server $CURRENT_SERVER exhausted, escalate to failover"
fi
# Whole UDP round failed (all servers × ports) → TCP, if any server offers it
if [ "$CURRENT_SERVER" -eq "$SERVER_COUNT" ]; then
CYCLES=$((CYCLES + 1)); echo "$CYCLES" > "$STATE/udp_cycles"
if try_tcp_after 0; then echo 0 > "$STATE/fail_count"; exit 0; fi
fi
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server

View File

@@ -1,4 +1,5 @@
#!/bin/sh
export PATH=/opt/bin:/opt/sbin:/opt/usr/bin:$PATH # cron без /opt/bin -> busybox wget висел вечно (09.29)
# ============================================================
# Phobos Config Pull — NAT-friendly management channel.
# Runs ON the router via cron (every 2 min). OUTBOUND ONLY:
@@ -21,9 +22,11 @@
# PANEL env or default below -> panel base URL
# ============================================================
PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf"
# 02.11.2026: доп. учётки — PHOBOS_ACC=$PHOBOS_DIR/acc/<клиент> (см. phobos-health.sh)
ACC_DIR="${PHOBOS_ACC:-$PHOBOS_DIR}"
CONF="$ACC_DIR/failover.conf"
HEALTH="$PHOBOS_DIR/phobos-health.sh"
LOG="$PHOBOS_DIR/health.log"
LOG="$ACC_DIR/health.log"
# Config sources, tried in order. TUNNEL FIRST: panel_tunnel_ip is the wg0 of
# whatever server the tunnel currently terminates on, so management rides the
# same obfuscated channel as data — survives a public-IP ban and follows
@@ -31,15 +34,18 @@ LOG="$PHOBOS_DIR/health.log"
# - panel_tunnel_ip:8444 -> secondary server agent (phobos-api)
# - panel_tunnel_ip:<panel_port> -> primary server panel
# - public panel IP -> bootstrap / if tunnel is down
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_DIR/panel_tunnel_ip" 2>/dev/null)}"
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$ACC_DIR/panel_tunnel_ip" 2>/dev/null)}"
[ -n "$PANEL_TUNNEL_IP" ] || PANEL_TUNNEL_IP="10.25.0.1"
PANEL="${PANEL:-$(cat "$PHOBOS_DIR/panel_url" 2>/dev/null)}"
PANEL_URLS="${PANEL_URLS:-$(cat "$PHOBOS_DIR/panel_urls" 2>/dev/null)}"
PANEL="${PANEL:-$(cat "$ACC_DIR/panel_url" 2>/dev/null)}"
PANEL_URLS="${PANEL_URLS:-$(cat "$ACC_DIR/panel_urls" 2>/dev/null)}"
[ -n "$PANEL_URLS" ] || PANEL_URLS="http://${PANEL_TUNNEL_IP}:8444 $PANEL"
CLIENT_ID=$(cat "$PHOBOS_DIR/client_id" 2>/dev/null || echo "home")
TOKEN=$(cat "$PHOBOS_DIR/pull_token" 2>/dev/null)
TMP="/tmp/failover.conf.pull"
LOCK="/tmp/phobos-pull.lock"
# + public URLs the panel announced in failover.conf (second IP etc., 09.33)
_extra=$(grep "^PANEL_URLS_EXTRA=" "$CONF" 2>/dev/null | cut -d= -f2-)
for _u in $_extra; do case " $PANEL_URLS " in *" $_u "*) ;; *) PANEL_URLS="$PANEL_URLS $_u" ;; esac; done
CLIENT_ID=$(cat "$ACC_DIR/client_id" 2>/dev/null || echo "home")
TOKEN=$(cat "$ACC_DIR/pull_token" 2>/dev/null)
TMP="/tmp/failover.conf.pull${PHOBOS_ACC:+-$CLIENT_ID}"
LOCK="/tmp/phobos-pull${PHOBOS_ACC:+-$CLIENT_ID}.lock"
log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; }
@@ -74,8 +80,21 @@ fetch_config() {
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
}
# TCP channel for routers (1.5.0): the guard needs /opt/bin/udptcp when failover.conf has TCP_N.
ensure_udptcp() {
[ -x /opt/bin/udptcp ] && return 0
grep -q "^TCP_[0-9]*=" "$CONF" 2>/dev/null || return 0
case "$(uname -m)" in aarch64) a=aarch64 ;; mips*) a=mipsel ;; *) return 0 ;; esac
command -v curl >/dev/null || return 0
{ curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/udptcp/bin/udptcp-$a" 2>/dev/null \
|| curl -fsSL -m 30 -o /opt/bin/udptcp.tmp "https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/udptcp/bin/udptcp-$a" 2>/dev/null; } \
&& [ -s /opt/bin/udptcp.tmp ] && chmod 755 /opt/bin/udptcp.tmp && mv /opt/bin/udptcp.tmp /opt/bin/udptcp \
&& log "PULL: udptcp installed ($a)" || rm -f /opt/bin/udptcp.tmp
}
# One fetch + compare + apply pass. Returns 0 always (best-effort).
do_pull() {
ensure_udptcp
# Try each source (tunnel first); accept first that returns a valid conf.
got=0
for base in $PANEL_URLS; do
@@ -102,6 +121,48 @@ do_pull() {
if [ "$old_s1" != "$new_s1" ] && [ -f "$HEALTH" ]; then
sh "$HEALTH" apply-server 1
fi
apply_obf_params
}
# Apply obfuscator params (masking/max-dummy/idle) delivered in failover.conf.
apply_obf_params() {
M=$(grep "^OBF_MASKING=" "$CONF" 2>/dev/null | cut -d= -f2-)
[ -z "$M" ] && return 0
D=$(grep "^OBF_MAX_DUMMY=" "$CONF" 2>/dev/null | cut -d= -f2-)
I=$(grep "^OBF_IDLE=" "$CONF" 2>/dev/null | cut -d= -f2-)
changed=0
# только свой обфускатор: у других учёток свои failover.conf
own=""
for l in "$PHOBOS_DIR"/wg-obfuscator*.link; do
[ "$(cat "$l" 2>/dev/null)" = "$CLIENT_ID" ] && own="${l%.link}.conf"
done
[ -z "$own" ] && [ -z "$PHOBOS_ACC" ] && own="$PHOBOS_DIR/wg-obfuscator*.conf"
for OBF in $own; do
[ -f "$OBF" ] || continue
cur=$(grep "^masking = " "$OBF" 2>/dev/null | sed 's/masking = //')
[ "$cur" != "$M" ] && { sed -i "s/^masking = .*/masking = $M/" "$OBF"; changed=1; }
if [ -n "$D" ]; then
cur=$(grep "^max-dummy = " "$OBF" 2>/dev/null | sed 's/max-dummy = //')
[ "$cur" != "$D" ] && { sed -i "s/^max-dummy = .*/max-dummy = $D/" "$OBF"; changed=1; }
fi
if [ -n "$I" ]; then
cur=$(grep "^idle-timeout = " "$OBF" 2>/dev/null | sed 's/idle-timeout = //')
[ "$cur" != "$I" ] && { sed -i "s/^idle-timeout = .*/idle-timeout = $I/" "$OBF"; changed=1; }
fi
done
[ "$changed" = 0 ] && return 0
log "PULL: obf params applied (masking=$M dummy=${D:-} idle=${I:-}), restart obfuscator"
if ls /opt/etc/init.d/S*obfuscator* >/dev/null 2>&1; then
for sc in /opt/etc/init.d/S*obfuscator*; do
for OBF in $own; do grep -q "/$(basename "$OBF")\"\$" "$sc" && [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; done
done
elif command -v systemctl >/dev/null 2>&1; then
systemctl restart 'wg-obfuscator*' >/dev/null 2>&1 || true
else
killall wg-obfuscator 2>/dev/null; sleep 1
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; do [ -f "$OBF" ] && wg-obfuscator --config "$OBF" >/dev/null 2>&1 & done
fi
}
# Inner loop: cron fires this every 60s, but we poll ~4x per minute so a

54
server/phobos-v6relay.py Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""IPv6 -> local IPv4 UDP relay for the obfuscator (it speaks IPv4 only).
Usage: phobos-v6relay.py LISTEN_PORT [TARGET_PORT] [TARGET_HOST]
One upstream socket per IPv6 client, so the obfuscator sees a stable
client address for the whole session. Idle clients are dropped after 300 s.
"""
import selectors
import socket
import sys
import time
IDLE = 300
listen_port = int(sys.argv[1])
target = (sys.argv[3] if len(sys.argv) > 3 else "127.0.0.1",
int(sys.argv[2]) if len(sys.argv) > 2 else listen_port)
down = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
down.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
down.bind(("::", listen_port))
sel = selectors.DefaultSelector()
sel.register(down, selectors.EVENT_READ, None)
clients = {} # v6 addr -> [upstream socket, last seen]
while True:
for key, _ in sel.select(timeout=30):
if key.data is None:
data, addr = down.recvfrom(65535)
c = clients.get(addr)
if c is None:
up = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
up.connect(target)
sel.register(up, selectors.EVENT_READ, addr)
c = clients[addr] = [up, 0]
c[1] = time.monotonic()
try:
c[0].send(data)
except OSError:
pass
else:
addr = key.data
try:
down.sendto(key.fileobj.recv(65535), addr)
clients[addr][1] = time.monotonic()
except OSError:
pass
now = time.monotonic()
for addr, (up, seen) in list(clients.items()):
if now - seen > IDLE:
sel.unregister(up)
up.close()
del clients[addr]