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>
This commit is contained in:
Бобырев Андрей
2026-10-23 02:22:35 +03:00
parent 093301eb51
commit 7eac63343d
65 changed files with 5337 additions and 1117 deletions

View File

@@ -443,6 +443,11 @@ deploy_health_monitor() {
chmod +x "$PHOBOS_DIR/phobos-pull.sh"
echo "{{CLIENT_NAME}}" > "$PHOBOS_DIR/client_id"
[ -f "pull_token" ] && cp "pull_token" "$PHOBOS_DIR/pull_token"
[ -f "panel_tunnel_ip" ] && cp "panel_tunnel_ip" "$PHOBOS_DIR/panel_tunnel_ip"
[ -f "panel_port" ] && cp "panel_port" "$PHOBOS_DIR/panel_port"
[ -f "panel_scheme" ] && cp "panel_scheme" "$PHOBOS_DIR/panel_scheme"
[ -f "panel_url" ] && cp "panel_url" "$PHOBOS_DIR/panel_url"
[ -f "panel_urls" ] && cp "panel_urls" "$PHOBOS_DIR/panel_urls"
log " Pull-агент установлен (client_id={{CLIENT_NAME}})"
fi

View File

@@ -56,11 +56,11 @@ action_add() {
for d in "$CLIENTS_DIR"/*; do
if [[ -d "$d" ]] && [[ -f "$d/metadata.json" ]]; then
local ip=$(jq -r '.tunnel_ip_v4 // empty' "$d/metadata.json" 2>/dev/null | cut -d/ -f1)
[[ -n "$ip" ]] && used_ips="${used_ips}${ip}"$'\n'
[[ -n "$ip" ]] && used_ips="${used_ips}${ip}\n"
fi
done
client_ip_v4="$(USED_IPS="$used_ips" python3 - "$server_wg_network" <<'PY'
client_ip_v4="$(USED_IPS="$(printf "%b" "$used_ips")" python3 - "$server_wg_network" <<'PY'
import ipaddress
import os
import sys
@@ -123,7 +123,7 @@ EOF
[instance]
source-if = 127.0.0.1
source-lport = ${CLIENT_WG_PORT:-13255}
target = $SERVER_PUBLIC_IP_V4:${OBFUSCATOR_PORT:-51821}
target = ${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORT:-51821}
key = ${OBFUSCATOR_KEY:-KEY}
masking = ${OBFUSCATOR_MASKING:-AUTO}
verbose = INFO
@@ -213,6 +213,7 @@ action_package() {
local tmp=$(mktemp -d)
local pkg_root="$tmp/phobos-$id"
mkdir -p "$PHOBOS_DIR/bin"
mkdir -p "$pkg_root/bin"
cp "$dir/${id}.conf" "$pkg_root/${id}.conf"
@@ -239,7 +240,7 @@ action_package() {
if [[ -d "$tpl_dir" ]]; then
for required in install-router.sh.template lib-client.sh install-obfuscator.sh install-wireguard.sh; do
[[ -f "$tpl_dir/$required" ]] || die "Обязательный шаблон $required не найден в $tpl_dir. Выполни phobos-update stable и пересоздай пакет."
[[ -f "$tpl_dir/$required" ]] || die "Обязательный шаблон $required не найден в $tpl_dir. Выполни phobos-update и пересоздай пакет."
done
cp "$tpl_dir/install-router.sh.template" "$pkg_root/install-router.sh"
sed -i "s|{{CLIENT_NAME}}|${id}|g" "$pkg_root/install-router.sh"
@@ -273,10 +274,42 @@ action_package() {
[[ -n "$PULL_TOKEN" ]] && echo "$PULL_TOKEN" > "$pkg_root/pull_token"
# Generate failover.conf with current server data
source "$PHOBOS_DIR/server/server.env"
local panel_port="${HTTP_PORT:-}"
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
fi
panel_port="${panel_port:-8443}"
local panel_scheme="${PANEL_SCHEME:-}"
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
fi
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
local panel_tunnel_ip=""
panel_tunnel_ip="$(python3 - "${SERVER_WG_IPV4_ADDRESS:-}" "${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}" <<'PY'
import ipaddress, sys
iface = (sys.argv[1] or "").strip()
network = (sys.argv[2] or "10.25.0.0/16").strip()
if iface:
try:
print(ipaddress.ip_interface(iface).ip)
raise SystemExit(0)
except Exception:
pass
net = ipaddress.ip_network(network, strict=False)
print(next(net.hosts()))
PY
)"
[[ -n "$panel_tunnel_ip" ]] && echo "$panel_tunnel_ip" > "$pkg_root/panel_tunnel_ip"
echo "$panel_port" > "$pkg_root/panel_port"
echo "$panel_scheme" > "$pkg_root/panel_scheme"
echo "${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}" > "$pkg_root/panel_url"
printf 'http://%s:8444 %s://%s:%s %s://%s:%s\n' \
"$panel_tunnel_ip" "$panel_scheme" "$panel_tunnel_ip" "$panel_port" \
"$panel_scheme" "$SERVER_PUBLIC_IP_V4" "$panel_port" > "$pkg_root/panel_urls"
cat > "$pkg_root/failover.conf" <<FAILEOF
# Phobos Failover Configuration
SERVER_1=${SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORTS:-51821,51822,51823}
KEY_1=${OBFUSCATOR_KEY}
SERVER_1=${CLIENT_ENDPOINT:-$SERVER_PUBLIC_IP_V4}:${OBFUSCATOR_PORTS:-51821,51822,51823}
KEY_1=${OBFUSCATOR_KEY:-}
FAILEOF
find "$pkg_root" -type f ! -path "*/bin/*" -exec sed -i 's/\r$//' {} \;
@@ -355,8 +388,13 @@ action_link() {
if [[ -z "$panel_port" && -f /opt/phobos-panel/.port ]]; then
panel_port=$(cat /opt/phobos-panel/.port 2>/dev/null || true)
fi
panel_port="${panel_port:-80}"
local primary_url="http://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz"
panel_port="${panel_port:-8443}"
local panel_scheme="${PANEL_SCHEME:-}"
if [[ -z "$panel_scheme" && -f /opt/phobos-panel/.scheme ]]; then
panel_scheme=$(cat /opt/phobos-panel/.scheme 2>/dev/null || true)
fi
case "$panel_scheme" in http|https) ;; *) panel_scheme="http";; esac
local primary_url="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/packages/$token/phobos-$id.tar.gz"
local fallback_url="http://${SERVER_PUBLIC_IP_V4}:80/packages/$token/phobos-$id.tar.gz"
cat > "$WWW_DIR/init/$token.sh" <<EOF
@@ -366,12 +404,12 @@ dir="/tmp/phobos_install_\$\$"
mkdir -p "\$dir"
echo "Downloading..."
ok=0
for url in \$urls; do
for url in \$urls; do
rm -f "\$dir/package.tar.gz"
if command -v curl >/dev/null; then
curl -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
curl -k -L -s -o "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
else
wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
wget --no-check-certificate -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || wget -q -O "\$dir/package.tar.gz" "\$url" 2>/dev/null || continue
fi
if grep -qi '<html' "\$dir/package.tar.gz" 2>/dev/null; then
echo "Bad response from \$url (HTML/proxy page), trying next..."
@@ -399,9 +437,9 @@ EOF
chmod 755 "$PACKAGES_DIR" 2>/dev/null
chmod 644 "$PACKAGES_DIR/phobos-$id.tar.gz" 2>/dev/null
local init_primary="http://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh"
local init_primary="${panel_scheme}://${SERVER_PUBLIC_IP_V4}:${panel_port}/init/$token.sh"
local init_fallback="http://${SERVER_PUBLIC_IP_V4}:80/init/$token.sh"
local cmd="tmp=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; do rm -f \$tmp; (wget -q -O \$tmp \$u || curl -fsSL -o \$tmp \$u) 2>/dev/null || continue; grep -qi '<html' \$tmp && continue; head -1 \$tmp | grep -q '^#!' || continue; sh \$tmp; ok=1; break; done; rm -f \$tmp; [ \$ok -eq 1 ] || echo 'ERROR: install script download failed (got HTML/proxy page or timeout)'"
local cmd="p=/tmp/phobos-init-\$\$.sh; ok=0; for u in $init_primary $init_fallback; do rm -f \$p; (wget -q -O \$p \$u || wget --no-check-certificate -q -O \$p \$u || curl -kfsSL -o \$p \$u || curl -fsSL -o \$p \$u) 2>/dev/null || continue; head -c 2 \$p | grep -q \"#!\" || continue; sh \$p && ok=1 && break; done; rm -f \$p; [ \$ok -eq 1 ] || echo ERROR_install_script_download_failed"
echo ""
echo "=================================================="

View File

@@ -24,14 +24,18 @@ PHOBOS_DIR="/opt/etc/Phobos"
CONF="$PHOBOS_DIR/failover.conf"
HEALTH="$PHOBOS_DIR/phobos-health.sh"
LOG="$PHOBOS_DIR/health.log"
# Config sources, tried in order. TUNNEL FIRST: 10.25.0.1 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 failover.
# - 10.25.0.1:8444 -> secondary server agent (phobos-api)
# - 10.25.0.1:10514 -> primary server panel
# 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
# failover.
# - 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="${PANEL:-http://212.118.52.193:10514}"
PANEL_URLS="${PANEL_URLS:-http://10.25.0.1:8444 http://10.25.0.1:10514 $PANEL}"
PANEL_TUNNEL_IP="${PANEL_TUNNEL_IP:-$(cat "$PHOBOS_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)}"
[ -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"
@@ -50,21 +54,24 @@ trap 'rm -f "$LOCK"' EXIT
[ -z "$TOKEN" ] && exit 0
detect_tunnel_iface() {
ip -o addr show 2>/dev/null | awk '$4 ~ /^10\.25\./ {print $2; exit}'
ip route get "$PANEL_TUNNEL_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1
}
fetch_config() {
base="$1"
url="${base}/api/router-config/${CLIENT_ID}?token=${TOKEN}"
case "$base" in
http://10.25.0.1:*|https://10.25.0.1:*)
"http://${PANEL_TUNNEL_IP}:"*|"https://${PANEL_TUNNEL_IP}:"*)
iface=$(detect_tunnel_iface)
if [ -n "$iface" ] && command -v curl >/dev/null; then
curl --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
curl -k --interface "$iface" -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
;;
esac
curl -s -m 8 -o "$TMP" "$url" 2>/dev/null
if command -v curl >/dev/null; then
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
fi
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
}
# One fetch + compare + apply pass. Returns 0 always (best-effort).
@@ -95,6 +102,40 @@ 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
for OBF in "$PHOBOS_DIR"/wg-obfuscator*.conf; 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 [ -x "$sc" ] && "$sc" restart >/dev/null 2>&1; 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

67
overlay/phobos-ttyd.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/sh
# ============================================================
# PCA Phobos — ttyd web-terminal на роутере Keenetic
#
# phobos-ttyd.sh <client_id> <token> [server_tunnel_ip]
#
# Ставит ttyd (web pty), открывает порт на WG-интерфейсе ТОЛЬКО для панели
# (server tunnel IP), создаёт init-сервис (переживает перезагрузку).
# Панель проксирует терминал к роутеру через WG-туннель (за NAT, без SSH).
# ============================================================
CID="${1:?usage: phobos-ttyd.sh <client_id> <token> [server_ip]}"
TOKEN="${2:?token required}"
SRV_IP="${3:-$(cat /opt/etc/Phobos/panel_tunnel_ip 2>/dev/null)}"
[ -n "$SRV_IP" ] || SRV_IP="10.25.0.1"
PORT="${TTYD_PORT:-7681}"
SHELL_BIN="$( [ -x /opt/bin/bash ] && echo /opt/bin/bash || echo sh )"
# WG tunnel netdev (best effort: interface holding RFC1918 or explicit panel route)
WGDEV=$(ip route get "$SRV_IP" 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
[ -z "$WGDEV" ] && WGDEV=nwg3
opkg update >/dev/null 2>&1
opkg install ttyd >/dev/null 2>&1
opkg install iptables >/dev/null 2>&1
TTYD_BIN="$(command -v ttyd || echo /opt/bin/ttyd)"
[ -x "$TTYD_BIN" ] || { echo "ERROR: ttyd not installed"; exit 1; }
mkdir -p /opt/etc/Phobos
echo "$TOKEN" > /opt/etc/Phobos/ttyd_token
chmod 600 /opt/etc/Phobos/ttyd_token
# init.d service: opens firewall for panel + runs ttyd (base-path + basic auth)
cat > /opt/etc/init.d/S51phobos-ttyd <<EOF
#!/bin/sh
PORT=$PORT
WGDEV=$WGDEV
SRV_IP=$SRV_IP
CID=$CID
TTYD=$TTYD_BIN
SH=$SHELL_BIN
TOKEN=\$(cat /opt/etc/Phobos/ttyd_token 2>/dev/null)
start() {
# firewall: allow ONLY the panel (server tunnel IP) to reach ttyd over WG
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
iptables -I INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT
pkill -f "ttyd -p \$PORT" 2>/dev/null; sleep 1
# busybox has no setsid/nohup; plain background detaches fine on Entware.
# no -i flag => ttyd binds all interfaces (0.0.0.0).
"\$TTYD" -p "\$PORT" -b "/ttyd/\$CID" -c "phobos:\$TOKEN" --writable "\$SH" >/tmp/phobos-ttyd.log 2>&1 &
sleep 1
echo "ttyd started on \$PORT (base /ttyd/\$CID, panel-only via \$SRV_IP)"
}
stop() {
pkill -f "ttyd -p \$PORT" 2>/dev/null
iptables -D INPUT -i "\$WGDEV" -s "\$SRV_IP" -p tcp --dport "\$PORT" -j ACCEPT 2>/dev/null
}
case "\$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 1; start ;;
*) echo "usage: \$0 {start|stop|restart}" ;;
esac
EOF
chmod +x /opt/etc/init.d/S51phobos-ttyd
/opt/etc/init.d/S51phobos-ttyd restart
echo "OK: ttyd на роутере, base-path /ttyd/$CID, порт $PORT (только для $SRV_IP по $WGDEV)."

View File

@@ -60,7 +60,7 @@ Options:
Example:
$0 --client-name Pegacomp \\
--client-private-key "ABCD..." \\
--client-ip 10.25.0.4 \\
--client-ip 10.26.0.4 \\
--client-ipv6 fd00:10:25::4 \\
--server-public-key "EFGH..." \\
--endpoint-port 13255 \\
@@ -195,6 +195,7 @@ configure_wireguard_interface() {
local description="Phobos-${CLIENT_NAME}"
local client_ip_addr=$(echo "${CLIENT_IP}" | cut -d'/' -f1)
local client_ipv6_block="${CLIENT_IPV6}"
local route_target=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0/16
log "Настройка интерфейса ${interface_name}..."
@@ -275,6 +276,31 @@ EOF
return 1
fi
# Настраиваем статический маршрут в созданный интерфейс отдельным вызовом RCI
local route_target_net=$(echo "$client_ip_addr" | cut -d'.' -f1-2).0.0
log "Настройка маршрута для подсети туннеля (${route_target_net}/16) -> ${interface_name}..."
local route_json=$(cat <<EOF
{
"network": "${route_target_net}",
"mask": "255.255.0.0",
"interface": "${interface_name}",
"auto": true
}
EOF
)
local route_result=$(echo "${route_json}" | curl -s -X POST \
-H "Content-Type: application/json" \
-d @- \
"http://localhost:79/rci/ip/route/" 2>/dev/null)
if echo "${route_result}" | jq -e '.status[] | select(.status == "error")' >/dev/null 2>&1; then
local error_msg=$(echo "${route_result}" | jq -r '.status[] | select(.status == "error") | .message // "Unknown error"' 2>/dev/null)
error "RCI API отклонил настройку маршрута: ${error_msg}"
log "JSON запрос:"
log "${route_json}"
return 1
fi
log "Интерфейс ${interface_name} создан ✓"
return 0