mirror of
https://github.com/andrey271192/PCA_Phobos.git
synced 2026-09-20 11:55:32 +00:00
feat: multi-server failover, health monitor, port hopping
- client/phobos-health.sh: router-side health monitor + auto-heal - Cron every 60s, checks WG handshake age - Auto-restart obfuscator (3min), WireGuard (5min) - Port hopping (10min), server failover (15min) - Auto-return to primary when recovered - client/failover.conf.example: multi-server config template - server/secondary-setup.sh: one-command secondary server deploy - WG + multi-port obfuscator + mini-API on :8444 - Auto-registers with main server - Panel: new "Серверы" page - Add/remove secondary servers - Health check, peer sync, fetch info - API key management - Secondary server install command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
222
app.py
222
app.py
@@ -419,6 +419,7 @@ def sessions_page():
|
|||||||
<a href="/sessions" class="active">Сессии ({online_count})</a>
|
<a href="/sessions" class="active">Сессии ({online_count})</a>
|
||||||
<a href="/clients">Клиенты</a>
|
<a href="/clients">Клиенты</a>
|
||||||
<a href="/labels">Метки</a>
|
<a href="/labels">Метки</a>
|
||||||
|
<a href="/servers">Серверы</a>
|
||||||
<a href="/settings">Настройки</a>
|
<a href="/settings">Настройки</a>
|
||||||
<a href="/logout">Выход</a>
|
<a href="/logout">Выход</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,6 +556,7 @@ def clients_page():
|
|||||||
<a href="/sessions">Сессии</a>
|
<a href="/sessions">Сессии</a>
|
||||||
<a href="/clients" class="active">Клиенты</a>
|
<a href="/clients" class="active">Клиенты</a>
|
||||||
<a href="/labels">Метки</a>
|
<a href="/labels">Метки</a>
|
||||||
|
<a href="/servers">Серверы</a>
|
||||||
<a href="/settings">Настройки</a>
|
<a href="/settings">Настройки</a>
|
||||||
<a href="/logout">Выход</a>
|
<a href="/logout">Выход</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,6 +614,7 @@ def labels_page():
|
|||||||
<a href="/sessions">Сессии</a>
|
<a href="/sessions">Сессии</a>
|
||||||
<a href="/clients">Клиенты</a>
|
<a href="/clients">Клиенты</a>
|
||||||
<a href="/labels" class="active">Метки</a>
|
<a href="/labels" class="active">Метки</a>
|
||||||
|
<a href="/servers">Серверы</a>
|
||||||
<a href="/settings">Настройки</a>
|
<a href="/settings">Настройки</a>
|
||||||
<a href="/logout">Выход</a>
|
<a href="/logout">Выход</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -725,6 +728,225 @@ def _render_install_commands():
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
SERVERS_FILE = f"{PANEL_DIR}/servers.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_servers():
|
||||||
|
if os.path.exists(SERVERS_FILE):
|
||||||
|
with open(SERVERS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_servers(servers):
|
||||||
|
with open(SERVERS_FILE, "w") as f:
|
||||||
|
json.dump(servers, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def check_server_health(server):
|
||||||
|
"""Check secondary server health via API."""
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
url = f"http://{server['ip']}:8444/api/health"
|
||||||
|
req = urllib.request.Request(url, headers={"X-API-Key": server.get("api_key", "")})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_peer_to_server(server, public_key, allowed_ips, action="add"):
|
||||||
|
"""Add or remove peer on secondary server."""
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
url = f"http://{server['ip']}:8444/api/peers/{action}"
|
||||||
|
data = json.dumps({"public_key": public_key, "allowed_ips": allowed_ips}).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": server.get("api_key", "")
|
||||||
|
})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception:
|
||||||
|
return {"status": "error"}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_peer_to_all_servers(public_key, allowed_ips, action="add"):
|
||||||
|
"""Sync peer to all secondary servers."""
|
||||||
|
servers = load_servers()
|
||||||
|
for srv in servers:
|
||||||
|
if srv.get("enabled", True):
|
||||||
|
sync_peer_to_server(srv, public_key, allowed_ips, action)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/servers/register", methods=["POST"])
|
||||||
|
def api_register_server():
|
||||||
|
s = load_settings()
|
||||||
|
api_key = request.headers.get("X-API-Key", "")
|
||||||
|
if api_key != s.get("server_api_key", ""):
|
||||||
|
return json.dumps({"error": "unauthorized"}), 401, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
servers = load_servers()
|
||||||
|
ip = data.get("ip", "")
|
||||||
|
|
||||||
|
for srv in servers:
|
||||||
|
if srv["ip"] == ip:
|
||||||
|
srv.update(data)
|
||||||
|
srv["last_seen"] = datetime.now().isoformat()
|
||||||
|
save_servers(servers)
|
||||||
|
return json.dumps({"status": "updated"}), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
data["last_seen"] = datetime.now().isoformat()
|
||||||
|
data["enabled"] = True
|
||||||
|
data["api_key"] = api_key
|
||||||
|
servers.append(data)
|
||||||
|
save_servers(servers)
|
||||||
|
return json.dumps({"status": "registered"}), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/servers", methods=["GET", "POST"])
|
||||||
|
@auth_required
|
||||||
|
def servers_page():
|
||||||
|
s = load_settings()
|
||||||
|
servers = load_servers()
|
||||||
|
msg = ""
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
action = request.form.get("action")
|
||||||
|
if action == "add":
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
api_key = request.form.get("api_key", "").strip()
|
||||||
|
if ip:
|
||||||
|
servers.append({
|
||||||
|
"ip": ip,
|
||||||
|
"api_key": api_key,
|
||||||
|
"enabled": True,
|
||||||
|
"last_seen": "",
|
||||||
|
"wg_public_key": "",
|
||||||
|
"obfuscator_key": "",
|
||||||
|
"ports": ""
|
||||||
|
})
|
||||||
|
save_servers(servers)
|
||||||
|
msg = f'<div class="alert alert-ok">Сервер {ip} добавлен</div>'
|
||||||
|
|
||||||
|
elif action == "remove":
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
servers = [s for s in servers if s.get("ip") != ip]
|
||||||
|
save_servers(servers)
|
||||||
|
msg = f'<div class="alert alert-ok">Сервер удалён</div>'
|
||||||
|
|
||||||
|
elif action == "sync":
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
srv = next((s for s in servers if s["ip"] == ip), None)
|
||||||
|
if srv:
|
||||||
|
clients = get_clients()
|
||||||
|
synced = 0
|
||||||
|
for c in clients:
|
||||||
|
pub = c.get("public_key", "")
|
||||||
|
tip = c.get("tunnel_ip_v4", "")
|
||||||
|
if pub and tip:
|
||||||
|
res = sync_peer_to_server(srv, pub, f"{tip}/32", "add")
|
||||||
|
if res.get("status") == "ok":
|
||||||
|
synced += 1
|
||||||
|
msg = f'<div class="alert alert-ok">Синхронизировано {synced} клиентов на {ip}</div>'
|
||||||
|
|
||||||
|
elif action == "fetch_info":
|
||||||
|
ip = request.form.get("ip", "").strip()
|
||||||
|
for srv in servers:
|
||||||
|
if srv["ip"] == ip:
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
url = f"http://{ip}:8444/api/info"
|
||||||
|
req = urllib.request.Request(url, headers={"X-API-Key": srv.get("api_key", "")})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
info = json.loads(resp.read())
|
||||||
|
srv["wg_public_key"] = info.get("wg_public_key", "")
|
||||||
|
srv["obfuscator_key"] = info.get("obfuscator_key", "")
|
||||||
|
srv["ports"] = ",".join(info.get("ports", []))
|
||||||
|
save_servers(servers)
|
||||||
|
msg = f'<div class="alert alert-ok">Инфо получено от {ip}</div>'
|
||||||
|
except Exception as e:
|
||||||
|
msg = f'<div class="alert alert-error">Ошибка: {e}</div>'
|
||||||
|
|
||||||
|
elif action == "set_api_key":
|
||||||
|
new_key = request.form.get("server_api_key", "").strip()
|
||||||
|
if new_key:
|
||||||
|
s["server_api_key"] = new_key
|
||||||
|
save_settings(s)
|
||||||
|
msg = '<div class="alert alert-ok">API ключ обновлён</div>'
|
||||||
|
|
||||||
|
# Build server rows
|
||||||
|
rows = ""
|
||||||
|
for srv in servers:
|
||||||
|
ip = srv.get("ip", "")
|
||||||
|
health = check_server_health(srv)
|
||||||
|
status_class = "badge-on" if health.get("status") == "ok" else "badge-off"
|
||||||
|
status_text = "Online" if health.get("status") == "ok" else "Offline"
|
||||||
|
peers = health.get("peers", "?")
|
||||||
|
ports = srv.get("ports", "?")
|
||||||
|
|
||||||
|
rows += f"""<tr>
|
||||||
|
<td>{ip}</td>
|
||||||
|
<td>{ports}</td>
|
||||||
|
<td>{peers}</td>
|
||||||
|
<td><span class="badge {status_class}">{status_text}</span></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="ip" value="{ip}">
|
||||||
|
<button name="action" value="sync" class="btn btn-primary btn-sm">Sync</button>
|
||||||
|
<button name="action" value="fetch_info" class="btn btn-primary btn-sm">Info</button>
|
||||||
|
<button name="action" value="remove" class="btn btn-danger btn-sm" onclick="return confirm('Удалить?')">✕</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
api_key = s.get("server_api_key", "")
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<div class="container">
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/sessions">Сессии</a>
|
||||||
|
<a href="/clients">Клиенты</a>
|
||||||
|
<a href="/labels">Метки</a>
|
||||||
|
<a href="/servers" class="active">Серверы</a>
|
||||||
|
<a href="/servers">Серверы</a>
|
||||||
|
<a href="/settings">Настройки</a>
|
||||||
|
<a href="/logout">Выход</a>
|
||||||
|
</div>
|
||||||
|
{msg}
|
||||||
|
<div class="card">
|
||||||
|
<h2>API ключ для secondary серверов</h2>
|
||||||
|
<form method="post" class="form-row">
|
||||||
|
<input type="hidden" name="action" value="set_api_key">
|
||||||
|
<input type="text" name="server_api_key" value="{api_key}" placeholder="API ключ" style="width:300px">
|
||||||
|
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Серверы VPN</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>IP</th><th>Порты</th><th>Peers</th><th>Статус</th><th></th></tr>
|
||||||
|
{rows}
|
||||||
|
</table>
|
||||||
|
<form method="post" class="form-row" style="margin-top:16px">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<input type="text" name="ip" placeholder="IP сервера" required>
|
||||||
|
<input type="text" name="api_key" placeholder="API ключ" style="width:200px">
|
||||||
|
<button class="btn btn-primary" type="submit">Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Установка secondary сервера</h2>
|
||||||
|
<p style="color:#94a3b8;font-size:.85em;margin-bottom:8px">На новом VPS выполнить:</p>
|
||||||
|
<code style="background:#0f172a;padding:8px 12px;border-radius:6px;display:block;font-size:.82em;word-break:break-all">MAIN_SERVER={SERVER_IP} MAIN_API_KEY={api_key} bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)</code>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
return render(html)
|
||||||
|
|
||||||
|
|
||||||
def _load_server_env():
|
def _load_server_env():
|
||||||
"""Load server.env as dict for subprocess env."""
|
"""Load server.env as dict for subprocess env."""
|
||||||
env = {}
|
env = {}
|
||||||
|
|||||||
15
client/failover.conf.example
Normal file
15
client/failover.conf.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Phobos Failover Configuration
|
||||||
|
# Format: SERVER_N=host:port1,port2,port3
|
||||||
|
# KEY_N=obfuscation_key_for_server_N
|
||||||
|
|
||||||
|
# Primary server (main, with web panel)
|
||||||
|
SERVER_1=144.124.252.104:51821,51822,51823
|
||||||
|
KEY_1=Fc3X8GbVQD2FuHPbOjKiaP83nWSwNojJ
|
||||||
|
|
||||||
|
# Secondary server (backup, VPN only)
|
||||||
|
# SERVER_2=backup.example.com:51821,51822
|
||||||
|
# KEY_2=backup_obfuscation_key_here
|
||||||
|
|
||||||
|
# Emergency server (last resort)
|
||||||
|
# SERVER_3=emergency.example.com:43210
|
||||||
|
# KEY_3=emergency_key_here
|
||||||
247
client/phobos-health.sh
Normal file
247
client/phobos-health.sh
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ============================================================
|
||||||
|
# Phobos Health Monitor — Auto-heal + Failover for routers
|
||||||
|
# Runs via cron every 60 seconds on Keenetic/Entware
|
||||||
|
#
|
||||||
|
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.sh
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
PHOBOS_DIR="/opt/etc/Phobos"
|
||||||
|
CONF="$PHOBOS_DIR/failover.conf"
|
||||||
|
STATE="$PHOBOS_DIR/state"
|
||||||
|
LOG="$PHOBOS_DIR/health.log"
|
||||||
|
WG_CONF="$PHOBOS_DIR/wg0.conf"
|
||||||
|
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
||||||
|
LOCKFILE="/tmp/phobos-health.lock"
|
||||||
|
|
||||||
|
MAX_LOG_LINES=200
|
||||||
|
HANDSHAKE_WARN=180
|
||||||
|
HANDSHAKE_RESTART_OBF=300
|
||||||
|
HANDSHAKE_RESTART_WG=600
|
||||||
|
HANDSHAKE_FAILOVER=900
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
||||||
|
# Trim log
|
||||||
|
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
||||||
|
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prevent concurrent runs
|
||||||
|
if [ -f "$LOCKFILE" ]; then
|
||||||
|
pid=$(cat "$LOCKFILE" 2>/dev/null)
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo $$ > "$LOCKFILE"
|
||||||
|
trap 'rm -f "$LOCKFILE"' EXIT
|
||||||
|
|
||||||
|
# Create state dir
|
||||||
|
mkdir -p "$STATE"
|
||||||
|
|
||||||
|
# ── Read failover config ──
|
||||||
|
if [ ! -f "$CONF" ]; then
|
||||||
|
log "ERROR: no failover.conf"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse servers: SERVER_1=ip:port1,port2,port3
|
||||||
|
# Parse current state
|
||||||
|
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
||||||
|
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
||||||
|
RESTART_COUNT=$(cat "$STATE/restart_count" 2>/dev/null || echo "0")
|
||||||
|
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
# Load server list
|
||||||
|
SERVER_COUNT=0
|
||||||
|
idx=1
|
||||||
|
while true; do
|
||||||
|
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
|
if [ -z "$val" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
||||||
|
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
||||||
|
eval "SERVER_${idx}_KEY=$(grep "^KEY_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)"
|
||||||
|
SERVER_COUNT=$idx
|
||||||
|
idx=$((idx + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$SERVER_COUNT" -eq 0 ]; then
|
||||||
|
log "ERROR: no servers in failover.conf"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Get current handshake age ──
|
||||||
|
get_handshake_age() {
|
||||||
|
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
|
||||||
|
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
|
||||||
|
echo "9999"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
now=$(date +%s)
|
||||||
|
age=$((now - hs))
|
||||||
|
echo "$age"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Get port by index from comma-separated list ──
|
||||||
|
get_port() {
|
||||||
|
server_idx=$1
|
||||||
|
port_idx=$2
|
||||||
|
eval "ports=\$SERVER_${server_idx}_PORTS"
|
||||||
|
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_port_count() {
|
||||||
|
server_idx=$1
|
||||||
|
eval "ports=\$SERVER_${server_idx}_PORTS"
|
||||||
|
echo "$ports" | tr ',' '\n' | wc -l
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Switch to specific server:port ──
|
||||||
|
switch_endpoint() {
|
||||||
|
server_idx=$1
|
||||||
|
port_idx=$2
|
||||||
|
|
||||||
|
eval "host=\$SERVER_${server_idx}_HOST"
|
||||||
|
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
||||||
|
port=$(get_port "$server_idx" "$port_idx")
|
||||||
|
|
||||||
|
if [ -z "$host" ] || [ -z "$port" ]; then
|
||||||
|
log "ERROR: invalid server $server_idx port_idx $port_idx"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "SWITCH → server $server_idx ($host:$port)"
|
||||||
|
|
||||||
|
# Update obfuscator config target
|
||||||
|
if [ -f "$OBF_CONF" ]; then
|
||||||
|
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
||||||
|
if [ -n "$obf_key" ]; then
|
||||||
|
sed -i "s|^key = .*|key = ${obf_key}|" "$OBF_CONF"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart obfuscator
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
|
||||||
|
# Save state
|
||||||
|
echo "$server_idx" > "$STATE/current_server"
|
||||||
|
echo "$port_idx" > "$STATE/current_port_idx"
|
||||||
|
echo "0" > "$STATE/restart_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Try next port on current server ──
|
||||||
|
try_next_port() {
|
||||||
|
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||||
|
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
||||||
|
|
||||||
|
if [ "$next_idx" -eq 0 ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Try next server ──
|
||||||
|
try_next_server() {
|
||||||
|
next=$((CURRENT_SERVER + 1))
|
||||||
|
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
||||||
|
next=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$next" -eq "$CURRENT_SERVER" ]; then
|
||||||
|
log "WARN: only one server, can't failover"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "FAILOVER → server $next"
|
||||||
|
switch_endpoint "$next" "0"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check if primary is back (every 5 min when on secondary) ──
|
||||||
|
check_primary() {
|
||||||
|
if [ "$CURRENT_SERVER" -eq 1 ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
now=$(date +%s)
|
||||||
|
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||||
|
if [ "$elapsed" -lt 300 ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "$now" > "$STATE/primary_check_ts"
|
||||||
|
|
||||||
|
eval "host=\$SERVER_1_HOST"
|
||||||
|
port=$(get_port 1 0)
|
||||||
|
|
||||||
|
# Quick UDP probe — send a byte, see if port responds
|
||||||
|
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
|
||||||
|
log "PRIMARY alive, switching back"
|
||||||
|
switch_endpoint 1 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main logic ──
|
||||||
|
AGE=$(get_handshake_age)
|
||||||
|
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
|
# All good, reset counters
|
||||||
|
if [ "$RESTART_COUNT" -gt 0 ]; then
|
||||||
|
log "OK: connection restored (age=${AGE}s)"
|
||||||
|
echo "0" > "$STATE/restart_count"
|
||||||
|
fi
|
||||||
|
check_primary
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_COUNT)"
|
||||||
|
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_RESTART_OBF" ]; then
|
||||||
|
# Restart obfuscator only
|
||||||
|
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||||
|
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
||||||
|
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_RESTART_WG" ]; then
|
||||||
|
# Restart WireGuard + obfuscator
|
||||||
|
RESTART_COUNT=$((RESTART_COUNT + 1))
|
||||||
|
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
||||||
|
log "ACTION: restart WG + obfuscator (attempt $RESTART_COUNT)"
|
||||||
|
wg-quick down wg0 2>/dev/null
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 2
|
||||||
|
wg-quick up wg0 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AGE" -lt "$HANDSHAKE_FAILOVER" ]; then
|
||||||
|
# Try next port
|
||||||
|
log "ACTION: try port hop"
|
||||||
|
if ! try_next_port; then
|
||||||
|
log "ACTION: all ports exhausted, try next server"
|
||||||
|
try_next_server
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full failover
|
||||||
|
log "ACTION: failover (handshake ${AGE}s)"
|
||||||
|
if ! try_next_server; then
|
||||||
|
# Only one server, cycle ports
|
||||||
|
try_next_port
|
||||||
|
fi
|
||||||
279
server/secondary-setup.sh
Normal file
279
server/secondary-setup.sh
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# Phobos Secondary Server Setup
|
||||||
|
# Deploys WG + obfuscator + mini-API (no web panel)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# MAIN_SERVER=144.124.252.104 MAIN_API_KEY=secret123 \
|
||||||
|
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/server/secondary-setup.sh)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MAIN_SERVER="${MAIN_SERVER:?Set MAIN_SERVER=ip_of_main_server}"
|
||||||
|
MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
||||||
|
OBF_PORTS="${OBF_PORTS:-51821,51822,51823}"
|
||||||
|
PHOBOS_DIR="/opt/Phobos"
|
||||||
|
|
||||||
|
SERVER_IP=$(curl -s https://api.ipify.org || hostname -I | awk '{print $1}')
|
||||||
|
IFACE=$(ip route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
||||||
|
IFACE="${IFACE:-eth0}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Phobos Secondary Server Setup ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ Server IP : $SERVER_IP"
|
||||||
|
echo "║ Main server : $MAIN_SERVER"
|
||||||
|
echo "║ OBF ports : $OBF_PORTS"
|
||||||
|
echo "╚══════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. Dependencies ──
|
||||||
|
echo "[1/6] Installing dependencies..."
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard jq curl python3 python3-flask gunicorn
|
||||||
|
|
||||||
|
# ── 2. Phobos binaries ──
|
||||||
|
echo "[2/6] Getting Phobos binaries..."
|
||||||
|
mkdir -p "$PHOBOS_DIR"/{server,clients,bin}
|
||||||
|
|
||||||
|
# Clone obfuscator binary from main Phobos repo
|
||||||
|
REPO_DIR="/tmp/phobos-repo"
|
||||||
|
rm -rf "$REPO_DIR"
|
||||||
|
mkdir -p "$REPO_DIR"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git init -q
|
||||||
|
git remote add origin https://github.com/Ground-Zerro/Phobos.git
|
||||||
|
git config core.sparseCheckout true
|
||||||
|
echo "wg-obfuscator" > .git/info/sparse-checkout
|
||||||
|
git pull origin main -q 2>/dev/null
|
||||||
|
cp -f wg-obfuscator/bin/wg-obfuscator-$(uname -m) "$PHOBOS_DIR/bin/" 2>/dev/null || true
|
||||||
|
chmod +x "$PHOBOS_DIR/bin/"wg-obfuscator-*
|
||||||
|
ln -sf "$PHOBOS_DIR/bin/wg-obfuscator-$(uname -m)" /usr/local/bin/wg-obfuscator
|
||||||
|
rm -rf "$REPO_DIR"
|
||||||
|
echo " Obfuscator installed."
|
||||||
|
|
||||||
|
# ── 3. WireGuard ──
|
||||||
|
echo "[3/6] Configuring WireGuard..."
|
||||||
|
WG_PRIV=$(wg genkey)
|
||||||
|
WG_PUB=$(echo "$WG_PRIV" | wg pubkey)
|
||||||
|
|
||||||
|
cat > /etc/wireguard/wg0.conf << WGEOF
|
||||||
|
[Interface]
|
||||||
|
Address = 10.25.0.1/16
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = $WG_PRIV
|
||||||
|
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE
|
||||||
|
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o $IFACE -j MASQUERADE
|
||||||
|
WGEOF
|
||||||
|
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 -q
|
||||||
|
grep -q "net.ipv4.ip_forward = 1" /etc/sysctl.conf || echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf
|
||||||
|
|
||||||
|
systemctl enable wg-quick@wg0 -q
|
||||||
|
systemctl restart wg-quick@wg0
|
||||||
|
echo " WireGuard running."
|
||||||
|
|
||||||
|
# ── 4. Obfuscator (multi-port) ──
|
||||||
|
echo "[4/6] Setting up obfuscator..."
|
||||||
|
OBF_KEY=$(head -c 32 /dev/urandom | base64 | tr -d "/+=" | head -c 32)
|
||||||
|
|
||||||
|
# Block direct WG access
|
||||||
|
iptables -C INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null \
|
||||||
|
|| iptables -A INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP
|
||||||
|
|
||||||
|
IFS=',' read -ra PORTS <<< "$OBF_PORTS"
|
||||||
|
for PORT in "${PORTS[@]}"; do
|
||||||
|
cat > "$PHOBOS_DIR/server/wg-obfuscator-${PORT}.conf" << EOF
|
||||||
|
[instance]
|
||||||
|
source-if = 0.0.0.0
|
||||||
|
source-lport = ${PORT}
|
||||||
|
target = 127.0.0.1:51820
|
||||||
|
key = ${OBF_KEY}
|
||||||
|
masking = AUTO
|
||||||
|
verbose = INFO
|
||||||
|
idle-timeout = 300
|
||||||
|
max-dummy = 50
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/wg-obfuscator-${PORT}.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=WireGuard Obfuscator (port ${PORT})
|
||||||
|
After=network.target wg-quick@wg0.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/wg-obfuscator --config ${PHOBOS_DIR}/server/wg-obfuscator-${PORT}.conf
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
for PORT in "${PORTS[@]}"; do
|
||||||
|
systemctl enable "wg-obfuscator-${PORT}" -q
|
||||||
|
systemctl start "wg-obfuscator-${PORT}"
|
||||||
|
done
|
||||||
|
echo " Obfuscator running on ports: $OBF_PORTS"
|
||||||
|
|
||||||
|
# ── 5. Save config ──
|
||||||
|
echo "[5/6] Saving config..."
|
||||||
|
cat > "$PHOBOS_DIR/server/server.env" << EOF
|
||||||
|
SERVER_WG_PRIVATE_KEY=$WG_PRIV
|
||||||
|
SERVER_WG_PUBLIC_KEY=$WG_PUB
|
||||||
|
SERVER_PUBLIC_IP_V4=$SERVER_IP
|
||||||
|
OBFUSCATOR_KEY=$OBF_KEY
|
||||||
|
OBFUSCATOR_PORTS=$OBF_PORTS
|
||||||
|
MAIN_SERVER=$MAIN_SERVER
|
||||||
|
MAIN_API_KEY=$MAIN_API_KEY
|
||||||
|
ROLE=secondary
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── 6. Mini-API ──
|
||||||
|
echo "[6/6] Setting up mini-API..."
|
||||||
|
cat > "$PHOBOS_DIR/server/api.py" << 'PYEOF'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Phobos Secondary Server API — peer management + health."""
|
||||||
|
import json, os, subprocess
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
env = {}
|
||||||
|
with open("/opt/Phobos/server/server.env") as f:
|
||||||
|
for line in f:
|
||||||
|
if "=" in line and not line.startswith("#"):
|
||||||
|
k, v = line.strip().split("=", 1)
|
||||||
|
env[k] = v
|
||||||
|
return env
|
||||||
|
|
||||||
|
def check_api_key():
|
||||||
|
env = load_env()
|
||||||
|
key = request.headers.get("X-API-Key", "")
|
||||||
|
return key == env.get("MAIN_API_KEY", "")
|
||||||
|
|
||||||
|
@app.route("/api/health")
|
||||||
|
def health():
|
||||||
|
try:
|
||||||
|
wg = subprocess.check_output(["wg", "show", "wg0"], text=True, timeout=5)
|
||||||
|
peers = wg.count("peer:")
|
||||||
|
return jsonify({"status": "ok", "peers": peers})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/peers", methods=["GET"])
|
||||||
|
def list_peers():
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["wg", "show", "wg0", "allowed-ips"], text=True, timeout=5)
|
||||||
|
peers = {}
|
||||||
|
for line in out.strip().split("\n"):
|
||||||
|
if "\t" in line:
|
||||||
|
pub, ips = line.split("\t", 1)
|
||||||
|
peers[pub.strip()] = ips.strip()
|
||||||
|
return jsonify({"peers": peers})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/peers/add", methods=["POST"])
|
||||||
|
def add_peer():
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
data = request.json
|
||||||
|
pub_key = data.get("public_key", "")
|
||||||
|
allowed_ips = data.get("allowed_ips", "")
|
||||||
|
if not pub_key or not allowed_ips:
|
||||||
|
return jsonify({"error": "missing public_key or allowed_ips"}), 400
|
||||||
|
try:
|
||||||
|
subprocess.run(["wg", "set", "wg0", "peer", pub_key, "allowed-ips", allowed_ips], check=True, timeout=5)
|
||||||
|
subprocess.run(["wg-quick", "save", "wg0"], timeout=5)
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/peers/remove", methods=["POST"])
|
||||||
|
def remove_peer():
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
pub_key = request.json.get("public_key", "")
|
||||||
|
if not pub_key:
|
||||||
|
return jsonify({"error": "missing public_key"}), 400
|
||||||
|
try:
|
||||||
|
subprocess.run(["wg", "set", "wg0", "peer", pub_key, "remove"], check=True, timeout=5)
|
||||||
|
subprocess.run(["wg-quick", "save", "wg0"], timeout=5)
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/info")
|
||||||
|
def info():
|
||||||
|
if not check_api_key():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
env = load_env()
|
||||||
|
return jsonify({
|
||||||
|
"ip": env.get("SERVER_PUBLIC_IP_V4"),
|
||||||
|
"wg_public_key": env.get("SERVER_WG_PUBLIC_KEY"),
|
||||||
|
"obfuscator_key": env.get("OBFUSCATOR_KEY"),
|
||||||
|
"ports": env.get("OBFUSCATOR_PORTS", "51821").split(","),
|
||||||
|
"role": "secondary"
|
||||||
|
})
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8444)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/phobos-api.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Phobos Secondary API
|
||||||
|
After=network.target wg-quick@wg0.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/Phobos/server
|
||||||
|
ExecStart=/usr/bin/gunicorn -w 1 -b 0.0.0.0:8444 api:app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable phobos-api -q
|
||||||
|
systemctl start phobos-api
|
||||||
|
|
||||||
|
# ── 7. Register with main server ──
|
||||||
|
echo ""
|
||||||
|
echo "Registering with main server..."
|
||||||
|
REG_DATA=$(cat << EOF
|
||||||
|
{
|
||||||
|
"ip": "$SERVER_IP",
|
||||||
|
"wg_public_key": "$WG_PUB",
|
||||||
|
"obfuscator_key": "$OBF_KEY",
|
||||||
|
"ports": "$(echo $OBF_PORTS)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
curl -s -X POST "http://${MAIN_SERVER}:8443/api/servers/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: ${MAIN_API_KEY}" \
|
||||||
|
-d "$REG_DATA" || echo " (Registration will be done manually via panel)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Secondary Server Ready! ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════╣"
|
||||||
|
echo "║ Server IP : $SERVER_IP"
|
||||||
|
echo "║ WG Public Key : $WG_PUB"
|
||||||
|
echo "║ OBF Key : $OBF_KEY"
|
||||||
|
echo "║ OBF Ports : $OBF_PORTS"
|
||||||
|
echo "║ API : http://$SERVER_IP:8444"
|
||||||
|
echo "║ Main server : $MAIN_SERVER"
|
||||||
|
echo "╚══════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user