Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad839e4cad | ||
|
|
28986677eb | ||
|
|
af4c269438 | ||
|
|
528d799edc | ||
|
|
31978f22f0 | ||
|
|
c5e9e45141 | ||
|
|
51ec84db6c | ||
|
|
d030344b00 | ||
|
|
8dc9fda65b | ||
|
|
8bfc84f315 | ||
|
|
381e97a564 | ||
|
|
eefe3fc2af | ||
|
|
15011c2756 | ||
|
|
29790aaf4d | ||
|
|
884df15f42 | ||
|
|
eb2b2768cd | ||
|
|
147ef3a42d |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,3 +1,7 @@
|
|||||||
|
## 1.4.9 — 2026-11-03
|
||||||
|
- Команда secondary сама подставляет подсеть главной; install.sh в сводке показывает реальные ключ API и пароль.
|
||||||
|
- Панель «Серверы»: сверху блок «Как добавить второй сервер» (по-русски, команда secondary, предупреждение про независимые панели и подсети); ручное добавление свёрнуто. install.sh: предупреждение перед вопросом о подсети; подсеть `10.25.11.0/16` → `10.25.11.0/24` вместо молчаливого `10.25.0.0/16`.
|
||||||
|
|
||||||
# Changelog — PCA Phobos
|
# Changelog — PCA Phobos
|
||||||
|
|
||||||
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
Каналы: **stable** (публичный, без токена) · **beta** / **dev** (приватный repo, токен подписчика).
|
||||||
@@ -5,6 +9,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.10 — 2026-11-03
|
||||||
|
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||||
|
|
||||||
## Как обновиться после фиксов
|
## Как обновиться после фиксов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -19,6 +26,26 @@ phobos-update beta # или: phobos-update dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.8 — сторож роутера не трогает чужие туннели
|
||||||
|
- Исправлено: если сторож не находил интерфейс Phobos (удалён или переименован), он брал Wireguard3 по умолчанию и прописывал туда пир Phobos — ломался посторонний WireGuard-туннель. Теперь он просто пропускает проверку.
|
||||||
|
- Роутерам: обновить скрипты из панели или переустановить клиента; серверу — `phobos-update stable`.
|
||||||
|
|
||||||
|
## 1.4.7 — роутеры Keenetic сами переключаются между серверами
|
||||||
|
- Исправлено: после переключения на другой сервер туннель на роутере переставал работать — пир прописывался на порт обфускатора 13255, а он мог слушать другой. Порт теперь берётся из `wg-obfuscator.conf`.
|
||||||
|
- Исправлено: проверка и обновление конфига по расписанию на Keenetic не запускались — задачи писались в `/opt/etc/crontab`, который демон не читает. Теперь busybox `crond` и crontab пользователя root.
|
||||||
|
- Исправлено: `phobos-pull` навсегда зависал на `wget`, если туннель не работает (таймаут 8 с).
|
||||||
|
- Роутерам: переустановить клиента или обновить скрипты из панели; серверу — `phobos-update stable`.
|
||||||
|
|
||||||
|
## 1.4.6 — роутеры снова получают смену адреса
|
||||||
|
- Исправлено: роутер получал 403 при pull конфига, если клиент создан через API/бота (`/api/client/ensure`) — смена адреса/сервера до него не доходила. Теперь принимается и общий ключ из пакета, и личный токен клиента. Роутеры переустанавливать не нужно — достаточно обновить панель.
|
||||||
|
|
||||||
|
## 1.4.5 — подсеть при установке, beta/dev по токену
|
||||||
|
|
||||||
|
- **feat:** beta и dev теперь берутся из приватного репозитория на Gitea (GitHub заблокирован): `phobos-update beta|dev` с токеном подписчика — в панели «Настройки → Версия и обновления → токен подписчика» или `GH_TOKEN=… phobos-update beta`. Без токена updater сообщает, что канал закрыт, и ничего не трогает.
|
||||||
|
- **fix:** при переустановке на сервер с готовым `wg0.conf` подсеть берётся из него.
|
||||||
|
|
||||||
|
- **feat:** подсеть VPN-клиентов задаётся при установке: `install.sh` и `secondary-setup.sh` спрашивают её в терминале (Enter = `10.25.0.0/16`) или берут `SERVER_WG_IPV4_NETWORK=10.25.1.0/24`. Неверная или публичная сеть — установка останавливается с ошибкой.
|
||||||
|
|
||||||
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
|
## 1.4.4 — канал «Облако» (через KeenDNS своего Keenetic)
|
||||||
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
|
- **Канал «Облако»** в Android PCA Phobos VPN 1.4.4 и iPhone (TestFlight 1.0 (6)): телефон → облако KeenDNS → ваш Keenetic → сервер. Помогает, когда сам сервер заблокирован, а сайты Keenetic открываются; белый IP роутеру не нужен.
|
||||||
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).
|
- **wsgw** — шлюз WebSocket → UDP для Keenetic (Entware: aarch64, mipsel, mips) и Linux. Инструкция с картинками — [docs/cloud.md](docs/cloud.md).
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
|
|||||||
|-------------------|-----------------|----------|
|
|-------------------|-----------------|----------|
|
||||||
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
|
| `PANEL_PASS` | `OcAdmin2026!` | Пароль панели (логин `admin`) |
|
||||||
| `PANEL_PORT` | случайный | Порт веб-панели |
|
| `PANEL_PORT` | случайный | Порт веб-панели |
|
||||||
|
| `SERVER_WG_IPV4_NETWORK` | `10.25.0.0/16` | Подсеть VPN-клиентов (например `10.25.1.0/24`). Без переменной установщик спросит в терминале. Сервер получает первый адрес (`10.25.1.1`). Задаётся только при установке |
|
||||||
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
|
| `API_KEY` | случайный | Общий ключ (агенты + pull-токен роутеров) |
|
||||||
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
| `OBF_PORTS` | `2083,5443,993` | Порты обфускатора |
|
||||||
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
| `TG_TOKEN`/`TG_CHAT` | пусто | Telegram-уведомления |
|
||||||
|
|||||||
127
app.py
127
app.py
@@ -802,6 +802,7 @@ _last_fanout = 0
|
|||||||
_last_peer_sync = 0
|
_last_peer_sync = 0
|
||||||
server_stats_cache = {}
|
server_stats_cache = {}
|
||||||
server_handshakes_cache = {}
|
server_handshakes_cache = {}
|
||||||
|
server_transfer_cache = {} # server ip -> {pub: [rx, tx]} (from secondary /api/health)
|
||||||
|
|
||||||
|
|
||||||
def count_client_peers():
|
def count_client_peers():
|
||||||
@@ -889,6 +890,7 @@ def get_remote_stats(server):
|
|||||||
raise Exception("no response")
|
raise Exception("no response")
|
||||||
# cache handshakes so page renders never block on remote HTTP
|
# cache handshakes so page renders never block on remote HTTP
|
||||||
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
|
server_handshakes_cache[server.get("ip", "")] = data.get("handshakes", {}) or {}
|
||||||
|
server_transfer_cache[server.get("ip", "")] = data.get("transfer", {}) or {}
|
||||||
# Filter peers: only count known client public keys
|
# Filter peers: only count known client public keys
|
||||||
clients = get_clients()
|
clients = get_clients()
|
||||||
client_pubs = {c.get("public_key", "") for c in clients}
|
client_pubs = {c.get("public_key", "") for c in clients}
|
||||||
@@ -1751,6 +1753,71 @@ def finish_legacy_update():
|
|||||||
finish_legacy_update()
|
finish_legacy_update()
|
||||||
threading.Thread(target=rate_sampler, daemon=True).start()
|
threading.Thread(target=rate_sampler, daemon=True).start()
|
||||||
|
|
||||||
|
# ---- Monthly traffic per client, summed over all servers; resets on the 1st ----
|
||||||
|
# WireGuard counters live per server and drop to 0 on wg restart/reboot, so we add deltas.
|
||||||
|
TRAFFIC_FILE = f"{PANEL_DIR}/traffic.json"
|
||||||
|
_traffic_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _traffic_load():
|
||||||
|
try:
|
||||||
|
with open(TRAFFIC_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_transfer():
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
for line in subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5).strip().split("\n")[1:]:
|
||||||
|
p = line.split("\t")
|
||||||
|
if len(p) >= 7:
|
||||||
|
out[p[0]] = [int(p[5]), int(p[6])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_sample():
|
||||||
|
month = datetime.now().strftime("%Y-%m")
|
||||||
|
with _traffic_lock:
|
||||||
|
d = _traffic_load()
|
||||||
|
if d.get("month") != month:
|
||||||
|
if d.get("usage"):
|
||||||
|
d.setdefault("history", {})[d.get("month", "?")] = d["usage"]
|
||||||
|
d["month"], d["usage"] = month, {}
|
||||||
|
prev, usage = d.setdefault("prev", {}), d.setdefault("usage", {})
|
||||||
|
pub2cid = {c.get("public_key"): c.get("client_id") for c in get_clients() if c.get("public_key")}
|
||||||
|
srcs = {SERVER_IP: _local_transfer()}
|
||||||
|
srcs.update(server_transfer_cache)
|
||||||
|
for ip, counters in srcs.items():
|
||||||
|
for pub, (rx, tx) in counters.items():
|
||||||
|
key, old, cid = ip + "|" + pub, prev.get(ip + "|" + pub), pub2cid.get(pub)
|
||||||
|
prev[key] = [rx, tx]
|
||||||
|
if old is None or not cid: # first sight = baseline only
|
||||||
|
continue
|
||||||
|
u = usage.setdefault(cid, [0, 0])
|
||||||
|
u[0] += rx - old[0] if rx >= old[0] else rx # counter went down = wg restarted
|
||||||
|
u[1] += tx - old[1] if tx >= old[1] else tx
|
||||||
|
tmp = TRAFFIC_FILE + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(d, f)
|
||||||
|
os.replace(tmp, TRAFFIC_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_sampler():
|
||||||
|
time.sleep(20)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
traffic_sample()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=traffic_sampler, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
PAGE = """<!DOCTYPE html>
|
PAGE = """<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@@ -2294,6 +2361,27 @@ def _terminal_button(url, title):
|
|||||||
f'style="background:#334155" title="{_html_attr(title)}">🖥️</a>')
|
f'style="background:#334155" title="{_html_attr(title)}">🖥️</a>')
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_bytes(n):
|
||||||
|
for u in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||||
|
if n < 1024 or u == "TiB":
|
||||||
|
return f"{n:.2f} {u}" if u != "B" else f"{int(n)} B"
|
||||||
|
n /= 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _traffic_cell(u):
|
||||||
|
if not u:
|
||||||
|
return "—"
|
||||||
|
return f"↓ {_fmt_bytes(u[1])} · ↑ {_fmt_bytes(u[0])}" # server tx = client download
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/traffic")
|
||||||
|
@auth_required
|
||||||
|
def api_traffic():
|
||||||
|
"""Monthly traffic per client: current month + history ({client: [rx, tx]}, server-side bytes)."""
|
||||||
|
d = _traffic_load()
|
||||||
|
return jsonify({"month": d.get("month"), "usage": d.get("usage", {}), "history": d.get("history", {})})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/clients", methods=["GET", "POST"])
|
@app.route("/clients", methods=["GET", "POST"])
|
||||||
@auth_required
|
@auth_required
|
||||||
def clients_page():
|
def clients_page():
|
||||||
@@ -2601,6 +2689,7 @@ def clients_page():
|
|||||||
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
<button class="btn btn-sm" style="background:#475569" type="submit">{tr("Имя","Name")}</button>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
|
_tusage = _traffic_load().get("usage", {}) if "_tusage" not in dir() else _tusage
|
||||||
lim = str(limits.get(cid, "") or "")
|
lim = str(limits.get(cid, "") or "")
|
||||||
rate = client_rates.get(pub)
|
rate = client_rates.get(pub)
|
||||||
speed = (f'<span class="cspeed" title="{tr("Скорость сейчас","Current speed")}">↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}</span>' if rate else "")
|
speed = (f'<span class="cspeed" title="{tr("Скорость сейчас","Current speed")}">↓{rate[0]:.1f} ↑{rate[1]:.1f} {tr("Мбит/с","Mbit/s")}</span>' if rate else "")
|
||||||
@@ -2613,7 +2702,7 @@ def clients_page():
|
|||||||
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
<button class="btn btn-primary btn-sm" type="submit">{set_lbl}</button>
|
||||||
</form>"""
|
</form>"""
|
||||||
rows += f"""<div class="ccard">
|
rows += f"""<div class="ccard">
|
||||||
<div class="ctop"><span class="cname">{client_cell}</span> <span class="cip">{ip}</span> {status} {expiry_badge} {f'<span class="badge badge-lock">≤{_html_attr(lim)} Mbit</span>' if lim and lim != "0" else ""} {speed}</div>
|
<div class="ctop"><span class="cname">{client_cell}</span> <span class="cip">{ip}</span> {status} {expiry_badge} <span class="cspeed" title="{tr('Трафик за месяц (суммарно по всем серверам)','Traffic this month (all servers)')}">{_traffic_cell(_tusage.get(cid))}</span> {f'<span class="badge badge-lock">≤{_html_attr(lim)} Mbit</span>' if lim and lim != "0" else ""} {speed}</div>
|
||||||
<div class="cctl">{server_cell}{expiry_cell}{limit_cell}{rename_box}</div>
|
<div class="cctl">{server_cell}{expiry_cell}{limit_cell}{rename_box}</div>
|
||||||
<div class="cact">{actions_cell}</div>
|
<div class="cact">{actions_cell}</div>
|
||||||
</div>"""
|
</div>"""
|
||||||
@@ -3449,8 +3538,9 @@ def router_config(client_id):
|
|||||||
st = load_settings()
|
st = load_settings()
|
||||||
token = request.args.get("token", "")
|
token = request.args.get("token", "")
|
||||||
acc = st.get("router_access", {}).get(client_id, {})
|
acc = st.get("router_access", {}).get(client_id, {})
|
||||||
expected = acc.get("pull_token", "") or st.get("server_api_key", "")
|
# packages ship server_api_key as pull_token (phobos-client.sh), so accept both
|
||||||
if not expected or token != expected:
|
expected = {t for t in (acc.get("pull_token", ""), st.get("server_api_key", "")) if t}
|
||||||
|
if not token or token not in expected:
|
||||||
return ("forbidden", 403)
|
return ("forbidden", 403)
|
||||||
conf = generate_failover_conf_for_client(client_id)
|
conf = generate_failover_conf_for_client(client_id)
|
||||||
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})
|
return (conf, 200, {"Content-Type": "text/plain; charset=utf-8"})
|
||||||
@@ -4478,6 +4568,26 @@ def servers_page():
|
|||||||
{nav('servers')}
|
{nav('servers')}
|
||||||
{msg}
|
{msg}
|
||||||
|
|
||||||
|
<div class="card" style="border:2px solid var(--accent)">
|
||||||
|
<h2 style="font-size:1.25em">➕ {tr("Как добавить второй сервер", "How to add a second server")}</h2>
|
||||||
|
<p style="font-size:1em;font-weight:600;margin:6px 0">{tr("Сервер управления один — эта панель. Второй VPS ставится как «дополнительный» (secondary) одной командой.", "There is one management server — this panel. The second VPS is installed as a secondary with one command.")}</p>
|
||||||
|
<ol style="margin:8px 0 8px 20px;line-height:1.6;font-size:.95em">
|
||||||
|
<li>{tr("Возьмите чистый VPS (Ubuntu/Debian). Если на нём уже стоял Phobos целиком — сначала удалите его (uninstall.sh).", "Take a clean VPS (Ubuntu/Debian). If a full Phobos was installed there, uninstall it first (uninstall.sh).")}</li>
|
||||||
|
<li>{tr("Зайдите на него по SSH под root и выполните команду ниже.", "SSH to it as root and run the command below.")}</li>
|
||||||
|
<li><b>{tr("Подсеть уже подставлена в команду (SERVER_WG_IPV4_NETWORK) — она такая же, как у этой панели, вопроса не будет.", "The subnet is already in the command (SERVER_WG_IPV4_NETWORK) — it is the same as on this panel, no prompt.")}</b> {tr("Не меняйте её и не вводите другую!", "Do not change it or enter another one!")}</li>
|
||||||
|
<li>{tr("Сервер сам появится в списке ниже, клиенты скопируются на него автоматически. Кнопка «Добавить сервер» не нужна.", "The server appears in the list below by itself and clients are copied to it automatically. The “Add Server” button is not needed.")}</li>
|
||||||
|
<li>{tr("Назначить клиенту сервер: Клиенты → «Редактировать» → «Сервер». Наши приложения (роутер, iOS, Android, Windows 1.4.5+) переключатся сами; другие клиенты — только повторным импортом ссылки.", "Assign a server to a client: Clients → Edit → Server. Our apps (router, iOS, Android, Windows 1.4.5+) switch by themselves; other clients need the link re-imported.")}</li>
|
||||||
|
</ol>
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-start">
|
||||||
|
<code id="secCmd" style="background:var(--bg);padding:8px 12px;border-radius:6px;display:block;font-size:.82em;word-break:break-all;flex:1">SERVER_WG_IPV4_NETWORK={server_wg_network()} MAIN_PROTO={panel_scheme()} MAIN_SERVER={SERVER_IP} MAIN_PORT={panel_port()} MAIN_API_KEY={api_key} bash <(curl -kfsSL {panel_base_url()}/server/secondary-setup.sh)</code>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" onclick="var x=document.createElement('textarea');x.value=document.getElementById('secCmd').textContent;document.body.appendChild(x);x.select();document.execCommand('copy');document.body.removeChild(x);this.textContent='{tr("Скопировано","Copied")}'">📋 {tr("Копировать","Copy")}</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:rgba(245,158,11,.15);border:1px solid #f59e0b;font-size:.9em;line-height:1.5">
|
||||||
|
<b>⚠ {tr("Не ставьте второй сервер полным install.sh!", "Do not install the second server with the full install.sh!")}</b><br>
|
||||||
|
{tr("Полный install.sh создаёт ОТДЕЛЬНУЮ панель со своими клиентами. Подсеть там придётся указывать свою (например 10.25.11.0/24, иначе у клиентов будут одинаковые IP и второй туннель на роутере не поднимется), а смена сервера у клиента между двумя панелями НЕ работает — это разные клиенты. Нужна смена и назначение серверов — ставьте только командой выше, подсеть по умолчанию (Enter).", "The full install.sh creates a SEPARATE panel with its own clients. You would have to enter your own subnet there (e.g. 10.25.11.0/24, otherwise clients get identical IPs and the second router tunnel will not come up), and switching a client's server between two panels does NOT work — they are different clients. If you need server assignment and switching, use only the command above with the default subnet (Enter).")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{tr("Панель серверов", "Server Dashboard")} {hlp("Живая статистика всех VPN-серверов. Peers = активные WG-подключения. Assigned = клиенты, у которых этот сервер основной. Фоновый монитор опрашивает каждые " + str(s.get('monitor_interval',30)) + " сек и шлёт оповещения в Telegram при падении/восстановлении сервера.", "Live stats for all VPN servers. Peers = active WG connections. Assigned = clients whose primary is this server. Background monitor polls every " + str(s.get('monitor_interval',30)) + "s and sends Telegram alerts on server up/down.")}</h2>
|
<h2>{tr("Панель серверов", "Server Dashboard")} {hlp("Живая статистика всех VPN-серверов. Peers = активные WG-подключения. Assigned = клиенты, у которых этот сервер основной. Фоновый монитор опрашивает каждые " + str(s.get('monitor_interval',30)) + " сек и шлёт оповещения в Telegram при падении/восстановлении сервера.", "Live stats for all VPN servers. Peers = active WG connections. Assigned = clients whose primary is this server. Background monitor polls every " + str(s.get('monitor_interval',30)) + "s and sends Telegram alerts on server up/down.")}</h2>
|
||||||
<div style="display:flex;gap:12px;flex-wrap:wrap">{stats_cards}</div>
|
<div style="display:flex;gap:12px;flex-wrap:wrap">{stats_cards}</div>
|
||||||
@@ -4497,6 +4607,7 @@ def servers_page():
|
|||||||
<button class="btn btn-primary" type="submit" onclick="return confirm('{tr("Отправить конфиг на все роутеры?","Push config to all routers?")}')">Push All</button></form>
|
<button class="btn btn-primary" type="submit" onclick="return confirm('{tr("Отправить конфиг на все роутеры?","Push config to all routers?")}')">Push All</button></form>
|
||||||
{hlp("Принудительно отправить конфиг на роутеры с прямым SSH. Роутеры за NAT это игнорируют и сами подтягивают конфиг за ~15 сек — обычно эта кнопка не нужна.", "Force-push config to routers reachable by SSH. NAT routers ignore it and auto-pull within ~15s — you usually do not need this.")}
|
{hlp("Принудительно отправить конфиг на роутеры с прямым SSH. Роутеры за NAT это игнорируют и сами подтягивают конфиг за ~15 сек — обычно эта кнопка не нужна.", "Force-push config to routers reachable by SSH. NAT routers ignore it and auto-pull within ~15s — you usually do not need this.")}
|
||||||
</div>
|
</div>
|
||||||
|
<details style="margin-top:16px"><summary style="cursor:pointer;color:var(--muted)">{tr("Сервер уже поставлен, но не появился в списке? Добавить вручную", "Server installed but not in the list? Add manually")}</summary>
|
||||||
<form method="post" class="form-row" style="margin-top:16px;flex-wrap:wrap">
|
<form method="post" class="form-row" style="margin-top:16px;flex-wrap:wrap">
|
||||||
<input type="hidden" name="action" value="add">
|
<input type="hidden" name="action" value="add">
|
||||||
<input type="text" name="ip" placeholder="{tr('IP сервера','Server IP')}" required>
|
<input type="text" name="ip" placeholder="{tr('IP сервера','Server IP')}" required>
|
||||||
@@ -4507,6 +4618,7 @@ def servers_page():
|
|||||||
<button class="btn btn-primary" type="submit">{tr("Добавить сервер","Add Server")}</button>
|
<button class="btn btn-primary" type="submit">{tr("Добавить сервер","Add Server")}</button>
|
||||||
{hlp("Добавить новый VPN-сервер в пул резервирования. Если secondary ставился командой ниже, API key можно оставить пустым — будет использован главный ключ панели. 8444 — это порт mini-API, не ключ.", "Add a new VPN server to the failover pool. If the secondary was installed with the command below, API key may be left blank — the panel main key will be used. 8444 is the mini-API port, not the key.")}
|
{hlp("Добавить новый VPN-сервер в пул резервирования. Если secondary ставился командой ниже, API key можно оставить пустым — будет использован главный ключ панели. 8444 — это порт mini-API, не ключ.", "Add a new VPN server to the failover pool. If the secondary was installed with the command below, API key may be left blank — the panel main key will be used. 8444 is the mini-API port, not the key.")}
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -4526,7 +4638,7 @@ def servers_page():
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Server SSH Access</h2>
|
<h2>{tr("SSH-доступ к серверам","Server SSH Access")}</h2>
|
||||||
<p style="color:var(--muted);font-size:.85em;margin-bottom:12px">SSH credentials for secondary servers. Used by panel to add/remove WG peers when switching client servers. Main server uses local commands.</p>
|
<p style="color:var(--muted);font-size:.85em;margin-bottom:12px">SSH credentials for secondary servers. Used by panel to add/remove WG peers when switching client servers. Main server uses local commands.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="action" value="save_server_access">
|
<input type="hidden" name="action" value="save_server_access">
|
||||||
@@ -4545,7 +4657,7 @@ def servers_page():
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>API Key</h2>
|
<h2>{tr("API-ключ","API Key")}</h2>
|
||||||
<form method="post" class="form-row">
|
<form method="post" class="form-row">
|
||||||
<input type="hidden" name="action" value="set_api_key">
|
<input type="hidden" name="action" value="set_api_key">
|
||||||
<input type="text" name="server_api_key" value="{api_key}" placeholder="API key" style="width:300px">
|
<input type="text" name="server_api_key" value="{api_key}" placeholder="API key" style="width:300px">
|
||||||
@@ -4553,11 +4665,6 @@ def servers_page():
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Deploy Secondary Server</h2>
|
|
||||||
<p style="color:var(--muted);font-size:.85em;margin-bottom:8px">One command deploys WG + obfuscator + mini-API on a new VPS. Auto-registers in panel. Run via SSH on new VPS:</p>
|
|
||||||
<code style="background:var(--bg);padding:8px 12px;border-radius:6px;display:block;font-size:.82em;word-break:break-all">MAIN_PROTO={panel_scheme()} MAIN_SERVER={SERVER_IP} MAIN_PORT={panel_port()} MAIN_API_KEY={api_key} bash <(curl -kfsSL {panel_base_url()}/server/secondary-setup.sh)</code>
|
|
||||||
</div>
|
|
||||||
</div>"""
|
</div>"""
|
||||||
return render(html)
|
return render(html)
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Phobos Health Monitor — Auto-heal + Failover for routers
|
# Phobos Health Monitor v2 — Keenetic Edition
|
||||||
# Runs via cron every 60 seconds on Keenetic/Entware
|
# Uses ndmc/RCI instead of wg-tools. Connectivity-based failover.
|
||||||
#
|
# Runs via cron every 60 seconds
|
||||||
# Install: crontab -e → */1 * * * * /opt/etc/Phobos/phobos-health.sh
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
PHOBOS_DIR="/opt/etc/Phobos"
|
PHOBOS_DIR="/opt/etc/Phobos"
|
||||||
CONF="$PHOBOS_DIR/failover.conf"
|
CONF="$PHOBOS_DIR/failover.conf"
|
||||||
STATE="$PHOBOS_DIR/state"
|
STATE="$PHOBOS_DIR/state"
|
||||||
LOG="$PHOBOS_DIR/health.log"
|
LOG="$PHOBOS_DIR/health.log"
|
||||||
WG_CONF="$PHOBOS_DIR/wg0.conf"
|
|
||||||
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
OBF_CONF="$PHOBOS_DIR/wg-obfuscator.conf"
|
||||||
LOCKFILE="/tmp/phobos-health.lock"
|
LOCKFILE="/tmp/phobos-health.lock"
|
||||||
|
WG_IF="Wireguard3"
|
||||||
|
|
||||||
MAX_LOG_LINES=200
|
MAX_LOG_LINES=200
|
||||||
HANDSHAKE_WARN=180
|
# Thresholds (seconds)
|
||||||
HANDSHAKE_RESTART_OBF=300
|
HANDSHAKE_WARN=150
|
||||||
HANDSHAKE_RESTART_WG=600
|
HANDSHAKE_PORT_HOP=300
|
||||||
HANDSHAKE_FAILOVER=900
|
HANDSHAKE_SERVER_SWITCH=600
|
||||||
|
PRIMARY_CHECK_INTERVAL=300
|
||||||
|
# Primary-alive probe: servers block ICMP and busybox `nc -z` is unreliable,
|
||||||
|
# so reachability is tested with curl against the panel's obfuscator-health
|
||||||
|
# endpoint on the primary VPS. It returns http 200 ONLY when all
|
||||||
|
# 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:-http}"
|
||||||
|
PRIMARY_PROBE_PORT="${PRIMARY_PROBE_PORT:-$(cat "$PHOBOS_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)
|
||||||
|
CHECK_HOST_1="8.8.8.8"
|
||||||
|
CHECK_HOST_2="1.1.1.1"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
echo "$(date '+%H:%M:%S') $1" >> "$LOG"
|
||||||
# Trim log
|
|
||||||
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
if [ "$(wc -l < "$LOG" 2>/dev/null)" -gt "$MAX_LOG_LINES" ]; then
|
||||||
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
tail -n 100 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||||
fi
|
fi
|
||||||
@@ -38,33 +51,35 @@ fi
|
|||||||
echo $$ > "$LOCKFILE"
|
echo $$ > "$LOCKFILE"
|
||||||
trap 'rm -f "$LOCKFILE"' EXIT
|
trap 'rm -f "$LOCKFILE"' EXIT
|
||||||
|
|
||||||
# Create state dir
|
|
||||||
mkdir -p "$STATE"
|
mkdir -p "$STATE"
|
||||||
|
|
||||||
|
# ── Detect WG interface ──
|
||||||
|
detect_wg_interface() {
|
||||||
|
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
|
||||||
|
WG_IF="Wireguard${i}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Read failover config ──
|
# ── Read failover config ──
|
||||||
if [ ! -f "$CONF" ]; then
|
if [ ! -f "$CONF" ]; then
|
||||||
log "ERROR: no failover.conf"
|
log "ERROR: no failover.conf"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
SERVER_COUNT=0
|
||||||
idx=1
|
idx=1
|
||||||
while true; do
|
while true; do
|
||||||
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
val=$(grep "^SERVER_${idx}=" "$CONF" 2>/dev/null | cut -d= -f2-)
|
||||||
if [ -z "$val" ]; then
|
[ -z "$val" ] && break
|
||||||
break
|
|
||||||
fi
|
|
||||||
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
eval "SERVER_${idx}_HOST=$(echo "$val" | cut -d: -f1)"
|
||||||
eval "SERVER_${idx}_PORTS=$(echo "$val" | cut -d: -f2-)"
|
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}_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-)"
|
||||||
SERVER_COUNT=$idx
|
SERVER_COUNT=$idx
|
||||||
idx=$((idx + 1))
|
idx=$((idx + 1))
|
||||||
done
|
done
|
||||||
@@ -74,22 +89,45 @@ if [ "$SERVER_COUNT" -eq 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Get current handshake age ──
|
# Read state
|
||||||
get_handshake_age() {
|
CURRENT_SERVER=$(cat "$STATE/current_server" 2>/dev/null || echo "1")
|
||||||
hs=$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}')
|
CURRENT_PORT_IDX=$(cat "$STATE/current_port_idx" 2>/dev/null || echo "0")
|
||||||
if [ -z "$hs" ] || [ "$hs" = "0" ]; then
|
FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
|
||||||
echo "9999"
|
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||||
return
|
|
||||||
fi
|
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
||||||
now=$(date +%s)
|
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
|
||||||
age=$((now - hs))
|
detect_wg_interface || {
|
||||||
echo "$age"
|
log "ERROR: own WG interface not found, skip"
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Get port by index from comma-separated list ──
|
# ── 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
|
||||||
|
# positive, sane) handshake across all peers — that is the live tunnel.
|
||||||
|
get_handshake_age() {
|
||||||
|
hs=$(ndmc -c "show interface $WG_IF" 2>/dev/null \
|
||||||
|
| grep "last-handshake" \
|
||||||
|
| awk '{v=$2} v>0 && v<86400 {print v}' \
|
||||||
|
| sort -n | head -1)
|
||||||
|
if [ -z "$hs" ]; then
|
||||||
|
echo "9999"
|
||||||
|
else
|
||||||
|
echo "$hs"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check real connectivity ──
|
||||||
|
check_connectivity() {
|
||||||
|
ping -c 1 -W 3 "$CHECK_HOST_1" >/dev/null 2>&1 && return 0
|
||||||
|
ping -c 1 -W 3 "$CHECK_HOST_2" >/dev/null 2>&1 && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Get port by index ──
|
||||||
get_port() {
|
get_port() {
|
||||||
server_idx=$1
|
server_idx=$1; port_idx=$2
|
||||||
port_idx=$2
|
|
||||||
eval "ports=\$SERVER_${server_idx}_PORTS"
|
eval "ports=\$SERVER_${server_idx}_PORTS"
|
||||||
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
echo "$ports" | tr ',' '\n' | sed -n "$((port_idx + 1))p"
|
||||||
}
|
}
|
||||||
@@ -100,6 +138,78 @@ get_port_count() {
|
|||||||
echo "$ports" | tr ',' '\n' | wc -l
|
echo "$ports" | tr ',' '\n' | wc -l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Keys currently on the interface (includes the interface's OWN key) ──
|
||||||
|
# WG public keys are 43 base64 chars + '='. The interface's own public-key is
|
||||||
|
# also matched here — callers must only act on KNOWN server WGKEYs so the
|
||||||
|
# local key is never touched.
|
||||||
|
list_iface_keys() {
|
||||||
|
ndmc -c "show interface $WG_IF" 2>/dev/null \
|
||||||
|
| grep -oE '[A-Za-z0-9+/]{43}=' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Count how many KNOWN server WGKEYs are currently attached as peers.
|
||||||
|
count_server_peers() {
|
||||||
|
present=$(list_iface_keys)
|
||||||
|
n=0
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
if [ -n "$wk" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
|
n=$((n + 1))
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
echo "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Switch WG peer so EXACTLY ONE server peer (new_key) remains ──
|
||||||
|
# Failover adds a new peer; the RCI "remove" is unreliable on Keenetic and
|
||||||
|
# leaves dead peers behind. Multiple peers each with allow-ips 0.0.0.0/0 make
|
||||||
|
# egress routing ambiguous. We hard-purge every OTHER known server key via the
|
||||||
|
# ndmc CLI (reliable) — never the interface's own key — then ensure new_key.
|
||||||
|
switch_wg_peer() {
|
||||||
|
new_key=$1
|
||||||
|
|
||||||
|
if [ -z "$new_key" ]; then
|
||||||
|
log "WARN: no WGKEY for target server, skip peer switch"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
present=$(list_iface_keys)
|
||||||
|
|
||||||
|
# Purge any OTHER known server key that is attached
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le "$SERVER_COUNT" ]; do
|
||||||
|
eval "wk=\$SERVER_${i}_WGKEY"
|
||||||
|
if [ -n "$wk" ] && [ "$wk" != "$new_key" ] && echo "$present" | grep -q "^${wk}$"; then
|
||||||
|
log "WG PEER purge: $wk"
|
||||||
|
ndmc -c "interface $WG_IF no wireguard peer $wk" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Add the target peer if it is not already present
|
||||||
|
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||||
|
log "WG PEER add: $new_key"
|
||||||
|
# 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=${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
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
|
||||||
|
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
|
||||||
|
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
|
||||||
|
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist config
|
||||||
|
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# ── Switch to specific server:port ──
|
# ── Switch to specific server:port ──
|
||||||
switch_endpoint() {
|
switch_endpoint() {
|
||||||
server_idx=$1
|
server_idx=$1
|
||||||
@@ -107,6 +217,7 @@ switch_endpoint() {
|
|||||||
|
|
||||||
eval "host=\$SERVER_${server_idx}_HOST"
|
eval "host=\$SERVER_${server_idx}_HOST"
|
||||||
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
eval "obf_key=\$SERVER_${server_idx}_KEY"
|
||||||
|
eval "wg_key=\$SERVER_${server_idx}_WGKEY"
|
||||||
port=$(get_port "$server_idx" "$port_idx")
|
port=$(get_port "$server_idx" "$port_idx")
|
||||||
|
|
||||||
if [ -z "$host" ] || [ -z "$port" ]; then
|
if [ -z "$host" ] || [ -z "$port" ]; then
|
||||||
@@ -116,7 +227,10 @@ switch_endpoint() {
|
|||||||
|
|
||||||
log "SWITCH → server $server_idx ($host:$port)"
|
log "SWITCH → server $server_idx ($host:$port)"
|
||||||
|
|
||||||
# Update obfuscator config target
|
# 1. Switch WG peer key if different server
|
||||||
|
switch_wg_peer "$wg_key"
|
||||||
|
|
||||||
|
# 2. Update obfuscator config
|
||||||
if [ -f "$OBF_CONF" ]; then
|
if [ -f "$OBF_CONF" ]; then
|
||||||
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
sed -i "s|^target = .*|target = ${host}:${port}|" "$OBF_CONF"
|
||||||
if [ -n "$obf_key" ]; then
|
if [ -n "$obf_key" ]; then
|
||||||
@@ -124,25 +238,26 @@ switch_endpoint() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Restart obfuscator
|
# 3. Restart obfuscator
|
||||||
killall wg-obfuscator 2>/dev/null
|
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||||
sleep 1
|
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
else
|
||||||
|
killall wg-obfuscator 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
|
fi
|
||||||
|
|
||||||
# Save state
|
# 4. Save state (fail_count is managed by the caller, NOT reset here —
|
||||||
|
# resetting on a port-hop would prevent escalation to server failover)
|
||||||
echo "$server_idx" > "$STATE/current_server"
|
echo "$server_idx" > "$STATE/current_server"
|
||||||
echo "$port_idx" > "$STATE/current_port_idx"
|
echo "$port_idx" > "$STATE/current_port_idx"
|
||||||
echo "0" > "$STATE/restart_count"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Try next port on current server ──
|
# ── Try next port on current server ──
|
||||||
try_next_port() {
|
try_next_port() {
|
||||||
port_count=$(get_port_count "$CURRENT_SERVER")
|
port_count=$(get_port_count "$CURRENT_SERVER")
|
||||||
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
next_idx=$(( (CURRENT_PORT_IDX + 1) % port_count ))
|
||||||
|
[ "$next_idx" -eq 0 ] && return 1
|
||||||
if [ "$next_idx" -eq 0 ]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
log "PORT HOP → port idx $next_idx on server $CURRENT_SERVER"
|
||||||
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
switch_endpoint "$CURRENT_SERVER" "$next_idx"
|
||||||
@@ -152,96 +267,203 @@ try_next_port() {
|
|||||||
# ── Try next server ──
|
# ── Try next server ──
|
||||||
try_next_server() {
|
try_next_server() {
|
||||||
next=$((CURRENT_SERVER + 1))
|
next=$((CURRENT_SERVER + 1))
|
||||||
if [ "$next" -gt "$SERVER_COUNT" ]; then
|
[ "$next" -gt "$SERVER_COUNT" ] && next=1
|
||||||
next=1
|
[ "$next" -eq "$CURRENT_SERVER" ] && return 1
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$next" -eq "$CURRENT_SERVER" ]; then
|
|
||||||
log "WARN: only one server, can't failover"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "FAILOVER → server $next"
|
log "FAILOVER → server $next"
|
||||||
switch_endpoint "$next" "0"
|
switch_endpoint "$next" "0"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Check if primary is back (every 5 min when on secondary) ──
|
# ── Check if primary is back ──
|
||||||
check_primary() {
|
check_primary() {
|
||||||
if [ "$CURRENT_SERVER" -eq 1 ]; then
|
[ "$CURRENT_SERVER" -eq 1 ] && return
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
now=$(date +%s)
|
now=$(date +%s)
|
||||||
elapsed=$((now - PRIMARY_CHECK_TS))
|
elapsed=$((now - PRIMARY_CHECK_TS))
|
||||||
if [ "$elapsed" -lt 300 ]; then
|
[ "$elapsed" -lt "$PRIMARY_CHECK_INTERVAL" ] && return
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "$now" > "$STATE/primary_check_ts"
|
echo "$now" > "$STATE/primary_check_ts"
|
||||||
|
|
||||||
eval "host=\$SERVER_1_HOST"
|
eval "host=\$SERVER_1_HOST"
|
||||||
port=$(get_port 1 0)
|
# Probe the obfuscator-health endpoint: http 200 ONLY when the primary's
|
||||||
|
# obfuscator path is actually up. Anything else (000 no-response, 503
|
||||||
# Quick UDP probe — send a byte, see if port responds
|
# obf-down, redirects) means the tunnel path is NOT viable → stay put.
|
||||||
if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then
|
ok_url=""
|
||||||
log "PRIMARY alive, switching back"
|
last_code=""
|
||||||
|
for url in "${PRIMARY_PROBE_SCHEME}://${host}:${PRIMARY_PROBE_PORT}${PRIMARY_PROBE_PATH}" "http://${host}:8444${PRIMARY_PROBE_PATH}"; do
|
||||||
|
code=$(curl -k -s -m 4 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
|
||||||
|
last_code="$code"
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
ok_url="$url"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$ok_url" ]; then
|
||||||
|
log "PRIMARY ($ok_url http=200) alive, switching back"
|
||||||
switch_endpoint 1 0
|
switch_endpoint 1 0
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
else
|
||||||
|
log "PRIMARY still down (obf-health http=${last_code:-none}), staying on server $CURRENT_SERVER"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Main logic ──
|
# ── LAN routing self-heal ──────────────────────
|
||||||
AGE=$(get_handshake_age)
|
# A router REBOOT silently drops the per-device `ip hotspot host <mac> policy`
|
||||||
|
# binding and can reset the WG interface security-level, so LAN clients leak to
|
||||||
|
# WAN (no VPN). This:
|
||||||
|
# 1) ensures WG_IF security-level = public (needed for masquerade),
|
||||||
|
# 2) LEARNS current host->policy bindings into state/lan-hosts (append-only),
|
||||||
|
# 3) RE-APPLIES any learned binding that is currently missing.
|
||||||
|
# Append-only learning means a reboot (which clears live bindings) never erases
|
||||||
|
# the record, so the next run restores them. ndmc show running-config is heavy,
|
||||||
|
# so the caller gates this to run only every few minutes.
|
||||||
|
heal_lan_routing() {
|
||||||
|
LANHOSTS="$STATE/lan-hosts"
|
||||||
|
rc=$(ndmc -c "show running-config" 2>/dev/null)
|
||||||
|
[ -z "$rc" ] && return 0
|
||||||
|
|
||||||
|
# 1) WG interface must be public
|
||||||
|
sl=$(echo "$rc" | awk -v ifc="interface $WG_IF" '$0~ifc{f=1} f&&/security-level/{print $2; exit}')
|
||||||
|
if [ -n "$sl" ] && [ "$sl" != "public" ]; then
|
||||||
|
ndmc -c "interface $WG_IF security-level public" >/dev/null 2>&1
|
||||||
|
ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
log "HEAL: $WG_IF security-level -> public"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) learn live bindings (append-only) into lan-hosts
|
||||||
|
touch "$LANHOSTS"
|
||||||
|
echo "$rc" | grep -oE "host [0-9a-f:]+ policy [A-Za-z0-9_]+" | while read -r _h mac _p pol; do
|
||||||
|
grep -q "^$mac " "$LANHOSTS" 2>/dev/null || echo "$mac $pol" >> "$LANHOSTS"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3) re-apply any learned binding that is missing live
|
||||||
|
changed=0
|
||||||
|
while read -r mac pol; do
|
||||||
|
[ -z "$mac" ] && continue
|
||||||
|
case "$mac" in \#*) continue;; esac
|
||||||
|
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"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
done < "$LANHOSTS"
|
||||||
|
[ "$changed" = 1 ] && ndmc -c "system configuration save" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Explicit apply hook (used by phobos-pull.sh after a config change).
|
||||||
|
# `phobos-health.sh apply-server N` re-points the tunnel to SERVER_N
|
||||||
|
# immediately, skipping the failure-escalation logic. Reuses
|
||||||
|
# switch_endpoint so the WG-peer/obfuscator/state changes stay identical
|
||||||
|
# to a normal failover. Resets fail_count so the new server starts clean.
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
if [ "$1" = "apply-server" ]; then
|
||||||
|
idx="${2:-1}"
|
||||||
|
eval "ahost=\$SERVER_${idx}_HOST"
|
||||||
|
if [ -z "$ahost" ]; then
|
||||||
|
log "APPLY: server $idx not in conf, ignore"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "APPLY: pull requested server $idx ($ahost)"
|
||||||
|
switch_endpoint "$idx" "0"
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
echo "$idx" > "$STATE/current_server"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Pre-check: fix desync (obfuscator targeting wrong server)
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
if [ -f "$OBF_CONF" ]; then
|
||||||
|
cur_target=$(grep "^target = " "$OBF_CONF" 2>/dev/null | sed 's/target = //')
|
||||||
|
eval "expected_host=\$SERVER_${CURRENT_SERVER}_HOST"
|
||||||
|
if [ -n "$cur_target" ] && [ -n "$expected_host" ]; then
|
||||||
|
echo "$cur_target" | grep -q "$expected_host" || {
|
||||||
|
log "DESYNC: obf=$cur_target state=server${CURRENT_SERVER}($expected_host). Resync."
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "$CURRENT_PORT_IDX"
|
||||||
|
sleep 5
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── LAN routing self-heal (gated ~5 min; a reboot drops host->policy bindings) ──
|
||||||
|
HEAL_TS=$(cat "$STATE/heal_ts" 2>/dev/null || echo 0)
|
||||||
|
now_heal=$(date +%s)
|
||||||
|
if [ $((now_heal - HEAL_TS)) -ge 300 ]; then
|
||||||
|
echo "$now_heal" > "$STATE/heal_ts"
|
||||||
|
heal_lan_routing
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Peer hygiene: keep exactly ONE WG peer = current server's key ──
|
||||||
|
# Failovers can leave dead/duplicate peers; multiple 0.0.0.0/0 peers cause
|
||||||
|
# ambiguous egress routing. Self-heal here every run (cheap when already clean).
|
||||||
|
eval "cur_wgkey=\$SERVER_${CURRENT_SERVER}_WGKEY"
|
||||||
|
if [ -n "$cur_wgkey" ]; then
|
||||||
|
peer_n=$(count_server_peers)
|
||||||
|
if [ "$peer_n" -gt 1 ]; then
|
||||||
|
log "HYGIENE: $peer_n server peers present, purging to server${CURRENT_SERVER}"
|
||||||
|
switch_wg_peer "$cur_wgkey"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
# Main logic
|
||||||
|
# Tunnel health = WireGuard handshake age (authoritative: a fresh
|
||||||
|
# handshake only happens through the full obfuscator→server→WG path).
|
||||||
|
# WAN ping is logged for context ONLY — it does NOT gate decisions,
|
||||||
|
# because the router's default route is not the tunnel, so WAN can be
|
||||||
|
# up while the tunnel is dead (and a WAN blip must not cause failover).
|
||||||
|
# ══════════════════════════════════════════════
|
||||||
|
AGE=$(get_handshake_age)
|
||||||
|
WAN=$(check_connectivity && echo "yes" || echo "no")
|
||||||
|
|
||||||
|
# Tunnel healthy = fresh handshake
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
if [ "$AGE" -lt "$HANDSHAKE_WARN" ]; then
|
||||||
# All good, reset counters
|
if [ "$FAIL_COUNT" -gt 0 ]; then
|
||||||
if [ "$RESTART_COUNT" -gt 0 ]; then
|
log "OK: tunnel up (handshake=${AGE}s, server=$CURRENT_SERVER, wan=$WAN)"
|
||||||
log "OK: connection restored (age=${AGE}s)"
|
echo "0" > "$STATE/fail_count"
|
||||||
echo "0" > "$STATE/restart_count"
|
|
||||||
fi
|
fi
|
||||||
check_primary
|
check_primary
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "STALE handshake: ${AGE}s (server=$CURRENT_SERVER port_idx=$CURRENT_PORT_IDX restarts=$RESTART_COUNT)"
|
# Stale handshake = tunnel down → escalate.
|
||||||
|
# fail_count is MONOTONIC: it climbs across stages and is only reset by the
|
||||||
|
# OK branch (real recovery) or after a successful failover to a NEW server
|
||||||
|
# (so the new server gets a fresh restart→port-hop→failover cycle).
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
echo "$FAIL_COUNT" > "$STATE/fail_count"
|
||||||
|
log "STALE: handshake=${AGE}s wan=${WAN} server=$CURRENT_SERVER port=$CURRENT_PORT_IDX fails=$FAIL_COUNT"
|
||||||
|
|
||||||
if [ "$AGE" -lt "$HANDSHAKE_RESTART_OBF" ]; then
|
# Stage 1 (fail 1): Restart obfuscator
|
||||||
# Restart obfuscator only
|
if [ "$FAIL_COUNT" -eq 1 ]; then
|
||||||
RESTART_COUNT=$((RESTART_COUNT + 1))
|
log "ACTION: restart obfuscator"
|
||||||
echo "$RESTART_COUNT" > "$STATE/restart_count"
|
if [ -f /opt/etc/init.d/S49wg-obfuscator ]; then
|
||||||
log "ACTION: restart obfuscator (attempt $RESTART_COUNT)"
|
/opt/etc/init.d/S49wg-obfuscator restart >/dev/null 2>&1
|
||||||
killall wg-obfuscator 2>/dev/null
|
else
|
||||||
sleep 1
|
killall wg-obfuscator 2>/dev/null
|
||||||
wg-obfuscator --config "$OBF_CONF" &
|
sleep 1
|
||||||
exit 0
|
wg-obfuscator --config "$OBF_CONF" &
|
||||||
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
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Full failover
|
# Stage 2 (fail 2): Port hop to an alternate port on the SAME server
|
||||||
log "ACTION: failover (handshake ${AGE}s)"
|
if [ "$FAIL_COUNT" -eq 2 ]; then
|
||||||
if ! try_next_server; then
|
if try_next_port; then
|
||||||
# Only one server, cycle ports
|
exit 0
|
||||||
try_next_port
|
fi
|
||||||
|
# only one port → fall through to server failover
|
||||||
|
log "single port, escalate to failover"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage 3 (fail 3+): Server failover. Reset fail_count so the new server
|
||||||
|
# gets its own restart→port-hop→failover cycle next ticks.
|
||||||
|
log "ACTION: server failover (fail $FAIL_COUNT)"
|
||||||
|
if try_next_server; then
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
else
|
||||||
|
# Only one server — cycle back to port 0 and restart escalation
|
||||||
|
echo "0" > "$STATE/fail_count"
|
||||||
|
switch_endpoint "$CURRENT_SERVER" "0"
|
||||||
fi
|
fi
|
||||||
|
|||||||
42
install.sh
42
install.sh
@@ -29,7 +29,42 @@ PANEL_SCHEME="${PANEL_SCHEME:-http}"
|
|||||||
TG_TOKEN="${TG_TOKEN:-}"
|
TG_TOKEN="${TG_TOKEN:-}"
|
||||||
TG_CHAT="${TG_CHAT:-}"
|
TG_CHAT="${TG_CHAT:-}"
|
||||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||||
|
# Уже есть wg0.conf (переустановка) — подсеть берём из него, иначе server.env разойдётся с интерфейсом
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -f /etc/wireguard/wg0.conf ]; then
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - <<'PY' 2>/dev/null
|
||||||
|
import ipaddress, re
|
||||||
|
for ln in open("/etc/wireguard/wg0.conf"):
|
||||||
|
m = re.match(r"\s*Address\s*=\s*([^,\s]+)", ln)
|
||||||
|
if m:
|
||||||
|
print(ipaddress.ip_interface(m.group(1)).network); break
|
||||||
|
PY
|
||||||
|
)" || SERVER_WG_IPV4_NETWORK=""
|
||||||
|
fi
|
||||||
|
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||||
|
printf '\n!!! Это ВТОРОЙ сервер к уже работающей панели? Тогда прервите (Ctrl+C) и ставьте командой из панели\n «Серверы -> Как добавить второй сервер» (secondary-setup.sh), подсеть там по умолчанию.\n Своя подсеть нужна только для НЕЗАВИСИМОЙ панели (например 10.25.11.0/24, не пересекается с первой);\n смена сервера у клиентов между независимыми панелями не работает.\n\n' > /dev/tty
|
||||||
|
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||||
|
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||||
|
fi
|
||||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||||
|
except ValueError:
|
||||||
|
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
|
||||||
|
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
|
||||||
|
# Считаем, что нужна подсеть этого адреса, и берём /24.
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
|
||||||
|
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
|
||||||
|
except ValueError:
|
||||||
|
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
||||||
|
if net.version != 4 or not net.is_private or net.prefixlen > 29:
|
||||||
|
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||||
|
print(net)
|
||||||
|
PY
|
||||||
|
)" || exit 1
|
||||||
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
|
# Источник: Gitea git.bobyrev.com (основной), GitHub — запасной.
|
||||||
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
# Каналы stable/beta/dev — ветки main/beta/dev одного публичного репозитория.
|
||||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||||
@@ -336,6 +371,13 @@ sleep 2
|
|||||||
mkdir -p "$PANEL_DIR"
|
mkdir -p "$PANEL_DIR"
|
||||||
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
|
echo "$CHANNEL" > "$PANEL_DIR/.channel" 2>/dev/null || true
|
||||||
|
|
||||||
|
# при переустановке settings.json остаётся старый — в сводке показываем то, что реально в панели
|
||||||
|
_S="$PANEL_DIR/settings.json"
|
||||||
|
if [ -f "$_S" ]; then
|
||||||
|
API_KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("server_api_key") or sys.argv[2])' "$_S" "$API_KEY" 2>/dev/null || echo "$API_KEY")"
|
||||||
|
PANEL_PASS="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("admin_pass") or sys.argv[2])' "$_S" "$PANEL_PASS" 2>/dev/null || echo "$PANEL_PASS")"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Installation complete"
|
echo " Installation complete"
|
||||||
|
|||||||
@@ -456,27 +456,21 @@ deploy_health_monitor() {
|
|||||||
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
|
[ -f "$PHOBOS_DIR/phobos-pull.sh" ] || pull_line=""
|
||||||
|
|
||||||
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
if [ "$ROUTER_PLATFORM" = "keenetic" ]; then
|
||||||
# Install cron if not present (Entware)
|
# Демон — busybox crond (есть в Entware как /opt/sbin/crond). Entware-пакет cron
|
||||||
if ! command -v crontab >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then
|
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
|
||||||
log " Установка cron через opkg..."
|
local I=/opt/etc/init.d/S10cron
|
||||||
opkg update >/dev/null 2>&1
|
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
|
||||||
opkg install cron >/dev/null 2>&1
|
[ -x $I ] && $I stop </dev/null >/dev/null 2>&1
|
||||||
/opt/etc/init.d/S10cron start >/dev/null 2>&1 || true
|
printf '#!/bin/sh\n\nENABLED=yes\nPROCS=crond\nARGS="-c /opt/var/spool/cron/crontabs -b"\nPREARGS=""\nDESC=$PROCS\nPATH=/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n. /opt/etc/init.d/rc.func\n' > $I
|
||||||
fi
|
chmod +x $I
|
||||||
local cron_file="/opt/etc/crontab"
|
|
||||||
# Create crontab file if missing
|
|
||||||
if [ ! -f "$cron_file" ]; then
|
|
||||||
mkdir -p /opt/etc
|
|
||||||
touch "$cron_file"
|
|
||||||
fi
|
|
||||||
if ! grep -q "phobos-health" "$cron_file" 2>/dev/null; then
|
|
||||||
echo "$cron_line" >> "$cron_file"
|
|
||||||
log " Cron (health) добавлен в $cron_file"
|
|
||||||
fi
|
|
||||||
if [ -n "$pull_line" ] && ! grep -q "phobos-pull" "$cron_file" 2>/dev/null; then
|
|
||||||
echo "$pull_line" >> "$cron_file"
|
|
||||||
log " Cron (pull) добавлен в $cron_file"
|
|
||||||
fi
|
fi
|
||||||
|
[ -x /opt/sbin/crond ] || command -v crond >/dev/null 2>&1 || log " ВНИМАНИЕ: crond не найден — health/pull по расписанию работать не будут (opkg install cron или busybox с crond)"
|
||||||
|
mkdir -p /opt/var/spool/cron/crontabs
|
||||||
|
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
||||||
|
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
||||||
|
sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null || true
|
||||||
|
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
||||||
|
log " Cron добавлен через crontab (health + pull)"
|
||||||
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
|
/opt/etc/init.d/S10cron restart >/dev/null 2>&1 || true
|
||||||
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
elif [ "$ROUTER_PLATFORM" = "openwrt" ]; then
|
||||||
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
(crontab -l 2>/dev/null | grep -v "phobos-health" | grep -v "phobos-pull"; echo "$cron_line"; [ -n "$pull_line" ] && echo "$pull_line") | crontab -
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ action_package() {
|
|||||||
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
cp "$dir/wg-obfuscator.conf" "$pkg_root/wg-obfuscator.conf"
|
||||||
|
|
||||||
# Fetch missing router-arch binaries from ClusterM on-demand
|
# Fetch missing router-arch binaries from ClusterM on-demand
|
||||||
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
|
local _tag; _tag=$(curl -fsSL -m10 https://api.github.com/repos/ClusterM/wg-obfuscator/releases/latest 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') || _tag="" # ponytail: GitHub API rate limit/недоступность не должны ронять сборку пакета — берём уже скачанные бинарники
|
||||||
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
|
local _cb="https://github.com/ClusterM/wg-obfuscator/releases/download/${_tag}"
|
||||||
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
|
declare -A _am=([aarch64]="linux-arm64" [mipsel]="linux-mipsel-mips32" [mips]="linux-mips-mips32" [armv7]="linux-armv7-hf" [x86_64]="linux-x64")
|
||||||
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
for _arch in aarch64 mipsel mips armv7 x86_64; do
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ fetch_config() {
|
|||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
|
|||||||
@@ -69,11 +69,16 @@ def health():
|
|||||||
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
out = subprocess.check_output(["wg", "show", "wg0", "dump"], text=True, timeout=5)
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
for line in out.strip().split("\n")[1:]:
|
for line in out.strip().split("\n")[1:]:
|
||||||
parts = line.split("\t")
|
parts = line.split("\t")
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 4:
|
||||||
pub = parts[0]
|
pub = parts[0]
|
||||||
peer_keys.append(pub)
|
peer_keys.append(pub)
|
||||||
|
try:
|
||||||
|
transfer[pub] = [int(parts[5]), int(parts[6])]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
handshakes[pub] = int(parts[4])
|
handshakes[pub] = int(parts[4])
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -83,6 +88,7 @@ def health():
|
|||||||
peers = 0
|
peers = 0
|
||||||
peer_keys = []
|
peer_keys = []
|
||||||
handshakes = {}
|
handshakes = {}
|
||||||
|
transfer = {}
|
||||||
cpu = cpu_percent()
|
cpu = cpu_percent()
|
||||||
try:
|
try:
|
||||||
parts = Path("/proc/meminfo").read_text().splitlines()
|
parts = Path("/proc/meminfo").read_text().splitlines()
|
||||||
@@ -96,7 +102,7 @@ def health():
|
|||||||
mem_str = f"{used}/{total}MB"
|
mem_str = f"{used}/{total}MB"
|
||||||
except Exception:
|
except Exception:
|
||||||
mem_str = "?"
|
mem_str = "?"
|
||||||
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
return jsonify({"status": "ok", "peers": peers, "peer_keys": peer_keys, "handshakes": handshakes, "transfer": transfer, "cpu": cpu, "mem": mem_str, "net": net_bytes()})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/obf-health")
|
@app.route("/api/obf-health")
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ FAIL_COUNT=$(cat "$STATE/fail_count" 2>/dev/null || echo "0")
|
|||||||
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
PRIMARY_CHECK_TS=$(cat "$STATE/primary_check_ts" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
# ── Detect Phobos WG interface (non-fatal, fallback to Wireguard3) ──
|
||||||
|
# Нет своего интерфейса — выходим: запасной Wireguard3 бывает чужим туннелем.
|
||||||
detect_wg_interface || {
|
detect_wg_interface || {
|
||||||
log "WARN: detect failed, using default $WG_IF"
|
log "ERROR: own WG interface not found, skip"
|
||||||
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Get handshake age via ndmc ──
|
# ── Get handshake age via ndmc ──
|
||||||
@@ -189,9 +191,18 @@ switch_wg_peer() {
|
|||||||
# Add the target peer if it is not already present
|
# Add the target peer if it is not already present
|
||||||
if ! echo "$present" | grep -q "^${new_key}$"; then
|
if ! echo "$present" | grep -q "^${new_key}$"; then
|
||||||
log "WG PEER add: $new_key"
|
log "WG PEER add: $new_key"
|
||||||
curl -s -X POST "http://localhost:79/rci/" \
|
# 01.11.2026: порт — из конфига обфускатора (было зашито 13255, а установщик
|
||||||
-H "Content-Type: application/json" \
|
# берёт свободный, на части роутеров 51820 → после переключения туннель мёртв).
|
||||||
-d "{\"interface\":{\"${WG_IF}\":{\"wireguard\":{\"peer\":{\"key\":\"${new_key}\",\"comment\":\"Phobos VPS Server\",\"endpoint\":{\"address\":\"127.0.0.1:13255\"},\"keepalive-interval\":{\"interval\":25},\"allow-ips\":[{\"address\":\"0.0.0.0\",\"mask\":\"0.0.0.0\"},{\"address\":\"::\",\"mask\":\"0\"}]}}}}}" >/dev/null 2>&1
|
# Через 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=${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
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key keepalive-interval 25" >/dev/null 2>&1
|
||||||
|
ndmc -c "interface $WG_IF wireguard peer $new_key allow-ips 0.0.0.0 0.0.0.0" >/dev/null 2>&1
|
||||||
|
# ядро не всегда подхватывает allow-ips нового пира без перезапуска интерфейса
|
||||||
|
ndmc -c "interface $WG_IF down" >/dev/null 2>&1; sleep 1
|
||||||
|
ndmc -c "interface $WG_IF up" >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Persist config
|
# Persist config
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ fetch_config() {
|
|||||||
if command -v curl >/dev/null; then
|
if command -v curl >/dev/null; then
|
||||||
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
curl -k -s -m 8 -o "$TMP" "$url" 2>/dev/null && return 0
|
||||||
fi
|
fi
|
||||||
wget --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -q -O "$TMP" "$url" 2>/dev/null
|
wget -T 8 --no-check-certificate -q -O "$TMP" "$url" 2>/dev/null || wget -T 8 -q -O "$TMP" "$url" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
# One fetch + compare + apply pass. Returns 0 always (best-effort).
|
||||||
|
|||||||
@@ -18,7 +18,30 @@ MAIN_API_KEY="${MAIN_API_KEY:?Set MAIN_API_KEY=your_api_key}"
|
|||||||
MAIN_PROTO="${MAIN_PROTO:-http}"
|
MAIN_PROTO="${MAIN_PROTO:-http}"
|
||||||
MAIN_PORT="${MAIN_PORT:-8443}"
|
MAIN_PORT="${MAIN_PORT:-8443}"
|
||||||
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
OBF_PORTS="${OBF_PORTS:-2083,5443,993}"
|
||||||
|
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||||
|
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
||||||
|
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||||
|
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||||
|
fi
|
||||||
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
||||||
|
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
|
import ipaddress, sys
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||||
|
except ValueError:
|
||||||
|
# «10.25.11.0/16» — биты хоста заданы: раньше молча превращалось в 10.25.0.0/16
|
||||||
|
# (две установки с одинаковой подсетью → одинаковые IP клиентов, конфликт на роутере).
|
||||||
|
# Считаем, что нужна подсеть этого адреса, и берём /24.
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(sys.argv[1].strip().split("/")[0] + "/24", strict=False)
|
||||||
|
sys.stderr.write("NOTICE: subnet %s has host bits set, using %s\n" % (sys.argv[1], net))
|
||||||
|
except ValueError:
|
||||||
|
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
||||||
|
if net.version != 4 or not net.is_private or net.prefixlen > 29:
|
||||||
|
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||||
|
print(net)
|
||||||
|
PY
|
||||||
|
)" || exit 1
|
||||||
PHOBOS_DIR="/opt/Phobos"
|
PHOBOS_DIR="/opt/Phobos"
|
||||||
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
WG_SERVER_IPV4="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
import ipaddress, sys
|
import ipaddress, sys
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ echo " watchdog, бинарники, $PHOBOS_DIR, $PANEL_DIR"
|
|||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
if [ "${PURGE:-0}" != "1" ]; then
|
if [ "${PURGE:-0}" != "1" ]; then
|
||||||
printf "Точно удалить ВСЁ? [yes/NO]: "
|
printf "Точно удалить ВСЁ? [yes/NO]: "
|
||||||
read ans
|
read -r ans </dev/tty 2>/dev/null || read -r ans
|
||||||
[ "$ans" = "yes" ] || { echo "Отменено."; exit 0; }
|
# tolerate CR from Windows/phone terminals, spaces, case, "да"
|
||||||
|
ans=$(printf '%s' "$ans" | tr -d '\r[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||||
|
case "$ans" in yes|y|да|д) ;; *) echo "Отменено (нужно ввести yes)."; exit 0 ;; esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
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=$(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)
|
||||||
|
|||||||
17
update.sh
17
update.sh
@@ -42,9 +42,17 @@ restart_panel_service() {
|
|||||||
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
# since 1.4.0 all channels are branches of one public repo (no subscriber token)
|
||||||
is_private() { return 1; }
|
is_private() { return 1; }
|
||||||
|
|
||||||
|
PCA_GIT_DEV="${PCA_GIT_DEV:-https://git.bobyrev.com/PCA/PCA_Phobos-dev}"
|
||||||
|
|
||||||
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
|
fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallback
|
||||||
local ref="$1" rp="$2" out="$3" kind=branch
|
local ref="$1" rp="$2" out="$3" kind=branch
|
||||||
case "$ref" in v[0-9]*) kind=tag;; esac
|
case "$ref" in v[0-9]*) kind=tag;; esac
|
||||||
|
# beta/dev live in the private Gitea repo PCA_Phobos-dev: read-only Gitea token in $PANEL_DIR/.gh_token or GH_TOKEN
|
||||||
|
case "$ref" in beta|dev)
|
||||||
|
if [ -n "$GH_TOKEN" ]; then
|
||||||
|
curl -fsSL -m25 -H "Authorization: token $GH_TOKEN" "$PCA_GIT_DEV/raw/branch/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||||
|
fi;;
|
||||||
|
esac
|
||||||
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
||||||
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
|
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
|
||||||
}
|
}
|
||||||
@@ -241,6 +249,15 @@ case "${1:-}" in
|
|||||||
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
echo "Токен — по подписке: https://boosty.to/andrey27 . Стабильная (без токена): phobos-update stable"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
case "$ref" in beta|dev)
|
||||||
|
if [ -z "$GH_TOKEN" ]; then
|
||||||
|
echo "Каналы beta и dev — по токену подписчика (read-only токен Gitea)."
|
||||||
|
echo "Вставь токен в панели: Настройки → Версия и обновления → «токен подписчика», или:"
|
||||||
|
echo " GH_TOKEN=ваш_токен phobos-update $ref"
|
||||||
|
echo "Стабильная (без токена): phobos-update stable"
|
||||||
|
exit 1
|
||||||
|
fi;;
|
||||||
|
esac
|
||||||
if ! ref_exists "$ref"; then
|
if ! ref_exists "$ref"; then
|
||||||
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
echo "Версия/ветка '$ref' не найдена (или токен неверный). Обновление отменено (текущая $CUR не тронута)."
|
||||||
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
echo "Доступные: phobos-update --versions · стабильная: $(latest_version)"
|
||||||
|
|||||||
Reference in New Issue
Block a user