Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f4f334a5 | ||
|
|
ad839e4cad | ||
|
|
28986677eb | ||
|
|
af4c269438 | ||
|
|
528d799edc | ||
|
|
31978f22f0 | ||
|
|
c5e9e45141 | ||
|
|
51ec84db6c |
3
APPS
Normal file
3
APPS
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Android (PCA Phobos VPN)=1.4.6
|
||||||
|
iPhone (TestFlight)=1.4.5
|
||||||
|
Windows=1.4.5-beta
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,3 +1,10 @@
|
|||||||
|
## 1.4.11 — 2026-11-11
|
||||||
|
- Панель сама раз в час проверяет свой канал (stable/beta/dev): если вышла новая версия — сверху зелёная полоса «Доступна новая версия» и кнопка «Обновить». В окне перед обновлением — что нового, как сделать бэкап и как откатиться.
|
||||||
|
|
||||||
|
## 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 +12,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1.4.10 — 2026-11-03
|
||||||
|
- Трафик за месяц на клиента (суммарно по всем серверам, обнуляется 1-го числа): колонка в «Клиентах», /api/traffic, история по месяцам. Secondary отдают счётчики в /api/health — secondary обновить, иначе их трафик не учитывается.
|
||||||
|
|
||||||
## Как обновиться после фиксов
|
## Как обновиться после фиксов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
|
|||||||
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
|
||||||
|
|
||||||
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
|
||||||
|
DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
|
||||||
|
|
||||||
### Вторичный сервер (для failover / балансировки)
|
### Вторичный сервер (для failover / балансировки)
|
||||||
|
|
||||||
|
|||||||
414
app.py
414
app.py
@@ -36,7 +36,7 @@ SECRET_FILE = f"{PANEL_DIR}/.secret_key"
|
|||||||
WG_CONFIG = "/etc/wireguard/wg0.conf"
|
WG_CONFIG = "/etc/wireguard/wg0.conf"
|
||||||
# Distribution: Gitea first (GitHub account is unavailable since 23.10.2026), GitHub fallback
|
# Distribution: Gitea first (GitHub account is unavailable since 23.10.2026), GitHub fallback
|
||||||
PCA_GIT = "https://git.bobyrev.com/PCA/PCA_Phobos"
|
PCA_GIT = "https://git.bobyrev.com/PCA/PCA_Phobos"
|
||||||
PCA_GH_RAW = "https://raw.githubusercontent.com/andrey271192/PCA_Phobos"
|
PCA_GH_RAW = "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos"
|
||||||
APK_TAG = "v1.4.4"
|
APK_TAG = "v1.4.4"
|
||||||
WARP_DIR = f"{PHOBOS_DIR}/server/warp"
|
WARP_DIR = f"{PHOBOS_DIR}/server/warp"
|
||||||
WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf"
|
WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf"
|
||||||
@@ -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}
|
||||||
@@ -1069,15 +1071,15 @@ UPDATE_BIN = "/usr/local/bin/phobos-update"
|
|||||||
def _update_repo_for_ref(ref):
|
def _update_repo_for_ref(ref):
|
||||||
ref = (ref or "").strip()
|
ref = (ref or "").strip()
|
||||||
if ref in ("beta", "dev"):
|
if ref in ("beta", "dev"):
|
||||||
return "andrey271192/PCA_Phobos-dev", ref
|
return "adleibzon-rgb/PCA_Phobos-dev", ref
|
||||||
if ref in ("stable", "main", "") or ref.startswith("v"):
|
if ref in ("stable", "main", "") or ref.startswith("v"):
|
||||||
return "andrey271192/PCA_Phobos", "main" if ref in ("stable", "") else ref
|
return "adleibzon-rgb/PCA_Phobos", "main" if ref in ("stable", "") else ref
|
||||||
version = panel_version()
|
version = panel_version()
|
||||||
if "beta" in version:
|
if "beta" in version:
|
||||||
return "andrey271192/PCA_Phobos-dev", "beta"
|
return "adleibzon-rgb/PCA_Phobos-dev", "beta"
|
||||||
if "dev" in version:
|
if "dev" in version:
|
||||||
return "andrey271192/PCA_Phobos-dev", "dev"
|
return "adleibzon-rgb/PCA_Phobos-dev", "dev"
|
||||||
return "andrey271192/PCA_Phobos", "main"
|
return "adleibzon-rgb/PCA_Phobos", "main"
|
||||||
|
|
||||||
|
|
||||||
def _current_update_ref(arg):
|
def _current_update_ref(arg):
|
||||||
@@ -1196,6 +1198,34 @@ def client_endpoint(ip):
|
|||||||
|
|
||||||
|
|
||||||
_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$")
|
_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$")
|
||||||
|
DEFAULT_CLIENT_DNS = "1.1.1.1, 8.8.8.8"
|
||||||
|
|
||||||
|
|
||||||
|
def client_dns():
|
||||||
|
return _load_server_env().get("CLIENT_DNS", "").strip() or DEFAULT_CLIENT_DNS
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_client_dns(wg):
|
||||||
|
"""Issued .conf files keep the DNS they were born with; serve the current one."""
|
||||||
|
return re.sub(r"(?mi)^DNS\s*=.*$", "DNS = " + client_dns(), wg)
|
||||||
|
|
||||||
|
|
||||||
|
def save_client_dns(form):
|
||||||
|
raw = (form.get("client_dns", "") or "").replace(";", ",")
|
||||||
|
items = [x.strip() for x in raw.split(",") if x.strip()]
|
||||||
|
for x in items:
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(x)
|
||||||
|
except ValueError:
|
||||||
|
return False, tr("Неверный DNS: ", "Bad DNS: ") + x
|
||||||
|
try:
|
||||||
|
lines = [ln for ln in open(SERVER_ENV).read().splitlines() if not ln.startswith("CLIENT_DNS=")]
|
||||||
|
if items and ", ".join(items) != DEFAULT_CLIENT_DNS:
|
||||||
|
lines.append("CLIENT_DNS=" + ",".join(items)) # no spaces: server.env is also `source`d
|
||||||
|
Path(SERVER_ENV).write_text("\n".join(lines) + "\n")
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"server.env: {e}"
|
||||||
|
return True, "DNS = " + client_dns()
|
||||||
|
|
||||||
|
|
||||||
def save_client_endpoints(form):
|
def save_client_endpoints(form):
|
||||||
@@ -1249,6 +1279,93 @@ def run_speedtest(ip):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- Google geo (09.30): how Google sees the server's addresses ---------------
|
||||||
|
GOOGLE_CLOSED = {"Russia", "Belarus", "China", "Iran", "Hong Kong", "North Korea", "Cuba", "Syria"}
|
||||||
|
|
||||||
|
|
||||||
|
def _google_country(*args):
|
||||||
|
"""«Country version» at the bottom of policies.google.com/terms = the country Google assigns to the address."""
|
||||||
|
import re as _re
|
||||||
|
import subprocess as _sp
|
||||||
|
try:
|
||||||
|
html = _sp.run(["curl", *args, "-s", "-m", "15", "https://policies.google.com/terms?hl=en"],
|
||||||
|
capture_output=True, text=True, timeout=25).stdout
|
||||||
|
m = _re.search(r"Country version:\s*</a>\s*([A-Za-z][A-Za-z .'-]*?)\s*<", html)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def google_geo():
|
||||||
|
"""v4/v6 = default route (what clients exit with); ips = every public IPv4 on the server."""
|
||||||
|
import re as _re
|
||||||
|
import subprocess as _sp
|
||||||
|
import time as _t
|
||||||
|
out = {"v4": _google_country("-4"), "v6": _google_country("-6"), "ips": {}, "ts": int(_t.time())}
|
||||||
|
try:
|
||||||
|
for line in _sp.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True,
|
||||||
|
text=True, timeout=10).stdout.splitlines():
|
||||||
|
ip = line.split()[3].split("/")[0]
|
||||||
|
if not _re.match(r"^(10|127|172\.(1[6-9]|2\d|3[01])|192\.168|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7]))\.", ip):
|
||||||
|
out["ips"][ip] = _google_country("-4", "--interface", ip)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_google_geo_busy = set()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_google_geo(ip):
|
||||||
|
"""Background refresh of google_geo for one server (main: locally, secondary: mini-API)."""
|
||||||
|
if ip in _google_geo_busy:
|
||||||
|
return
|
||||||
|
_google_geo_busy.add(ip)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
if not ip or ip == SERVER_IP:
|
||||||
|
res = google_geo()
|
||||||
|
else:
|
||||||
|
srv = next((x for x in load_servers() if x.get("ip") == ip), None)
|
||||||
|
res = secondary_api_request(srv, "/api/google-geo", None, timeout=120) if srv else None
|
||||||
|
if res and "v4" in res:
|
||||||
|
s = load_settings()
|
||||||
|
s.setdefault("google_geo", {})[ip or SERVER_IP] = res
|
||||||
|
save_settings(s)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_google_geo_busy.discard(ip)
|
||||||
|
import threading as _th
|
||||||
|
_th.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def google_geo_html(ip):
|
||||||
|
"""Card lines: «Google видит: IPv4 X · IPv6 Y» (+ other IPv4), red + warning if Google marks it closed."""
|
||||||
|
import time as _t
|
||||||
|
g = load_settings().get("google_geo", {}).get(ip) or {}
|
||||||
|
if not g or _t.time() - g.get("ts", 0) > 3 * 3600:
|
||||||
|
refresh_google_geo(ip)
|
||||||
|
if not g:
|
||||||
|
return f'{tr("Google видит", "Google sees")}: <span>{tr("проверяется…", "checking…")}</span>'
|
||||||
|
def b(v):
|
||||||
|
col = "#e66" if v in GOOGLE_CLOSED else "var(--text)"
|
||||||
|
return f'<b style="color:{col}">{v or "—"}</b>'
|
||||||
|
others = " · ".join(f'{k} {b(v)}' for k, v in sorted((g.get("ips") or {}).items()) if k != ip)
|
||||||
|
ago = int((_t.time() - g.get("ts", 0)) / 60)
|
||||||
|
html = (f'{tr("Google видит", "Google sees")}: IPv4 {b(g.get("v4"))} · IPv6 {b(g.get("v6"))} '
|
||||||
|
f'<span style="opacity:.7">({ago} {tr("мин назад", "min ago")})</span>')
|
||||||
|
if others:
|
||||||
|
html += f'<br><span style="opacity:.8">{tr("другие IPv4", "other IPv4")}: {others}</span>'
|
||||||
|
bad = [v for v in (g.get("v4"), g.get("v6")) if v in GOOGLE_CLOSED]
|
||||||
|
if bad:
|
||||||
|
html += (f'<br><span style="color:#e66">{tr("Google считает адрес", "Google treats the address as")} «{", ".join(bad)}» — '
|
||||||
|
f'{tr("Gemini и часть сервисов Google у клиентов не откроются", "Gemini and some Google services will not open for clients")}</span>')
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
LIMITS_FILE = f"{PHOBOS_DIR}/server/limits.conf"
|
LIMITS_FILE = f"{PHOBOS_DIR}/server/limits.conf"
|
||||||
|
|
||||||
|
|
||||||
@@ -1400,7 +1517,7 @@ def build_phone_config(cid, mode="android"):
|
|||||||
instpath = os.path.join(cdir, "wg-obfuscator.conf")
|
instpath = os.path.join(cdir, "wg-obfuscator.conf")
|
||||||
if not os.path.exists(wgpath):
|
if not os.path.exists(wgpath):
|
||||||
return None, None
|
return None, None
|
||||||
wg = open(wgpath).read().rstrip()
|
wg = _apply_client_dns(open(wgpath).read().rstrip())
|
||||||
if mode == "ios":
|
if mode == "ios":
|
||||||
server_ip = SERVER_IP
|
server_ip = SERVER_IP
|
||||||
try:
|
try:
|
||||||
@@ -1434,7 +1551,8 @@ def build_phone_config(cid, mode="android"):
|
|||||||
inst += "\n# pca-tcp = " + tcp_port
|
inst += "\n# pca-tcp = " + tcp_port
|
||||||
conf = wg + "\n\n" + inst + "\n"
|
conf = wg + "\n\n" + inst + "\n"
|
||||||
payload = _prepare_phoboswg_payload(conf)
|
payload = _prepare_phoboswg_payload(conf)
|
||||||
b64 = base64.urlsafe_b64encode(payload.encode()).decode()
|
# spec 2.1: base64url WITHOUT padding (HydraRoute rejects a trailing "=")
|
||||||
|
b64 = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
|
||||||
link = "phobos://" + b64 + "#" + urllib.parse.quote(cid or "none")
|
link = "phobos://" + b64 + "#" + urllib.parse.quote(cid or "none")
|
||||||
return payload, link
|
return payload, link
|
||||||
|
|
||||||
@@ -1751,6 +1869,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">
|
||||||
@@ -1849,7 +2032,7 @@ textarea::placeholder{color:var(--muted)}
|
|||||||
</div>
|
</div>
|
||||||
CONTENT
|
CONTENT
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<b style="color:var(--accent)">PCA Phobos Panel</b> — панель управления © <a href="https://git.bobyrev.com/PCA/PCA_Phobos" target="_blank" rel="noopener">andrey271192</a><br>
|
<b style="color:var(--accent)">PCA Phobos Panel</b> — панель управления © <a href="https://git.bobyrev.com/PCA/PCA_Phobos" target="_blank" rel="noopener">adleibzon-rgb</a><br>
|
||||||
Поддержать проект: <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener">💖 Boosty</a> · <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener">💳 Ozon Bank</a> · <a href="https://t.me/PCAdministration" target="_blank" rel="noopener">✉️ Telegram</a>
|
Поддержать проект: <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener">💖 Boosty</a> · <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798" target="_blank" rel="noopener">💳 Ozon Bank</a> · <a href="https://t.me/PCAdministration" target="_blank" rel="noopener">✉️ Telegram</a>
|
||||||
<div style="margin-top:8px;font-size:.88em;color:var(--muted);line-height:1.7;text-align:left;max-width:660px;margin:8px auto 0">
|
<div style="margin-top:8px;font-size:.88em;color:var(--muted);line-height:1.7;text-align:left;max-width:660px;margin:8px auto 0">
|
||||||
<b>Создано на основе открытых проектов:</b><br>
|
<b>Создано на основе открытых проектов:</b><br>
|
||||||
@@ -1864,8 +2047,141 @@ function showHelp(el){var b=el.nextElementSibling; if(b&&b.classList.contains('h
|
|||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── «Доступна новая версия»: проверка канала раз в час + баннер с описанием ──
|
||||||
|
_upd_cache = {"ts": 0, "info": None, "busy": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_of(version):
|
||||||
|
v = (version or "").lower()
|
||||||
|
return "dev" if "dev" in v else ("beta" if "beta" in v else "stable")
|
||||||
|
|
||||||
|
|
||||||
|
def _ver_key(v):
|
||||||
|
"""1.4.10 > 1.4.9; 1.5.0-beta.2 > 1.5.0-beta.1; release > pre-release of the same number."""
|
||||||
|
v = (v or "").strip().lstrip("v")
|
||||||
|
base, _, pre = v.partition("-")
|
||||||
|
nums = [int(x) if x.isdigit() else 0 for x in base.split(".")[:3]]
|
||||||
|
nums += [0] * (3 - len(nums))
|
||||||
|
pre_n = [int(x) for x in re.findall(r"\d+", pre)] or [0]
|
||||||
|
return (nums, 0 if pre else 1, pre_n)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_channel_file(channel, name):
|
||||||
|
repo = "PCA_Phobos" if channel == "stable" else "PCA_Phobos-dev"
|
||||||
|
branch = {"stable": "main"}.get(channel, channel)
|
||||||
|
cmd = ["curl", "-fsSL", "-m", "10", f"https://git.bobyrev.com/PCA/{repo}/raw/branch/{branch}/{name}?cb={int(time.time())}"]
|
||||||
|
try:
|
||||||
|
tok = Path(GH_TOKEN_FILE).read_text().strip()
|
||||||
|
if tok and repo != "PCA_Phobos":
|
||||||
|
cmd[1:1] = ["-H", "Authorization: token " + tok]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if channel == "stable": # public: GitHub first, Gitea fallback (09.33)
|
||||||
|
g = subprocess.run(["curl", "-fsSL", "-m", "10", f"https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/{name}?cb={int(time.time())}"],
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
if g.returncode == 0 and g.stdout:
|
||||||
|
return g.stdout
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
return r.stdout if r.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _changelog_since(text, cur):
|
||||||
|
"""Sections '## X.Y.Z …' of CHANGELOG newer than the installed version."""
|
||||||
|
out, take = [], False
|
||||||
|
for ln in text.split("\n"):
|
||||||
|
m = re.match(r"^## +v?(\d+\.\d+\.\d+\S*)", ln)
|
||||||
|
if m:
|
||||||
|
take = _ver_key(m.group(1)) > _ver_key(cur)
|
||||||
|
elif ln.startswith("## "):
|
||||||
|
take = False
|
||||||
|
if take:
|
||||||
|
out.append(ln)
|
||||||
|
return "\n".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _apps_parse(text):
|
||||||
|
out = {}
|
||||||
|
for ln in (text or "").splitlines():
|
||||||
|
if "=" in ln and not ln.lstrip().startswith("#"):
|
||||||
|
k, v = ln.split("=", 1)
|
||||||
|
out[k.strip()] = v.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_update_info():
|
||||||
|
try:
|
||||||
|
cur = panel_version()
|
||||||
|
ch = _channel_of(cur)
|
||||||
|
latest = _fetch_channel_file(ch, "VERSION").strip()
|
||||||
|
info = None
|
||||||
|
try:
|
||||||
|
local_apps = _apps_parse(open(os.path.join(PANEL_DIR, "APPS")).read())
|
||||||
|
except Exception:
|
||||||
|
local_apps = {}
|
||||||
|
remote_apps = _apps_parse(_fetch_channel_file(ch, "APPS"))
|
||||||
|
apps = [(k, local_apps.get(k, ""), v) for k, v in remote_apps.items()
|
||||||
|
if not local_apps.get(k) or _ver_key(v) > _ver_key(local_apps.get(k))]
|
||||||
|
newer = bool(latest and re.match(r"^v?\d+\.\d+", latest) and _ver_key(latest) > _ver_key(cur))
|
||||||
|
if newer or apps:
|
||||||
|
info = {"channel": ch, "cur": cur, "latest": latest if newer else cur, "apps": apps,
|
||||||
|
"notes": _changelog_since(_fetch_channel_file(ch, "CHANGELOG.md"), cur)[:6000] if newer else ""}
|
||||||
|
_upd_cache["info"] = info
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_upd_cache["ts"] = time.time()
|
||||||
|
_upd_cache["busy"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def update_info():
|
||||||
|
if not _upd_cache["busy"] and time.time() - _upd_cache["ts"] > 3600:
|
||||||
|
_upd_cache["busy"] = True
|
||||||
|
threading.Thread(target=_refresh_update_info, daemon=True).start()
|
||||||
|
return _upd_cache["info"]
|
||||||
|
|
||||||
|
|
||||||
|
def update_banner():
|
||||||
|
try:
|
||||||
|
if not session.get("auth"):
|
||||||
|
return ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
i = update_info()
|
||||||
|
if not i:
|
||||||
|
return ""
|
||||||
|
e = html_lib.escape
|
||||||
|
notes = e(i["notes"] or tr("Описание изменений — в CHANGELOG.md репозитория.", "See CHANGELOG.md in the repository."))
|
||||||
|
apps = i.get("apps") or []
|
||||||
|
apps_line = (" · " + tr("новые приложения", "new apps") + ": " + ", ".join(e(f"{k} {v}") for k, _o, v in apps)) if apps else ""
|
||||||
|
apps_html = ("<p style=\"font-size:.88em\"><b>" + tr("Новые версии приложений", "New app versions") + ":</b><br>"
|
||||||
|
+ "<br>".join(e(f"{k}: {o or '—'} → {v}") for k, o, v in apps)
|
||||||
|
+ "<br>" + tr("После обновления панели новые ссылки на скачивание — в окне «Подключение» у клиента; разошлите их пользователям.", "After the panel update the new download links are in the client's Connection window; send them to your users.")
|
||||||
|
+ "</p>") if apps else ""
|
||||||
|
return f'''<div id="upd-bar" style="background:#16a34a;color:#fff;padding:8px 14px;text-align:center;font-size:.92em">
|
||||||
|
{tr("Доступна новая версия","New version available")} <b>{e(i["latest"])}</b> ({e(i["channel"])}) · {tr("у вас","you have")} {e(i["cur"])}{apps_line}
|
||||||
|
<button class="btn btn-sm" style="background:#fff;color:#16a34a;margin-left:10px" onclick="document.getElementById('upd-modal').style.display='flex'">{tr("Обновить","Update")}</button>
|
||||||
|
</div>
|
||||||
|
<div id="upd-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9999;align-items:center;justify-content:center;padding:16px">
|
||||||
|
<div class="card" style="max-width:640px;width:100%;max-height:85vh;overflow:auto;margin:0">
|
||||||
|
<h2>{tr("Обновление до","Update to")} {e(i["latest"])} ({e(i["channel"])})</h2>
|
||||||
|
<p style="color:var(--muted);font-size:.88em">{tr("Что нового","What's new")}:</p>
|
||||||
|
<pre style="white-space:pre-wrap;font-size:.85em;background:var(--bg);padding:10px;border-radius:8px">{notes}</pre>
|
||||||
|
{apps_html}
|
||||||
|
<p style="font-size:.88em"><b>{tr("Перед обновлением","Before updating")}:</b> {tr("панель сама делает бэкап PCA-слоя; ключи WireGuard, server.env и клиенты не трогаются. Свой полный бэкап (рекомендуем):","the panel backs up the PCA layer itself; WireGuard keys, server.env and clients are not touched. Your own full backup (recommended):")}<br>
|
||||||
|
<code>tar czf /root/phobos-backup-$(date +%F).tgz /opt/Phobos /opt/phobos-panel</code></p>
|
||||||
|
<p style="font-size:.88em"><b>{tr("Если что-то сломалось","If something breaks")}:</b> {tr("Настройки → «Откатить на предыдущую» или в консоли","Settings → Rollback, or in the console")} <code>phobos-update --rollback</code>.
|
||||||
|
{tr("Обновлены secondary-серверы? Обновите и их той же кнопкой в их панели.","Have secondary servers? Update them the same way in their panel.")}</p>
|
||||||
|
<form method="post" action="/settings" style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px">
|
||||||
|
<input type="hidden" name="action" value="update"><input type="hidden" name="target" value="{e(i["channel"])}">
|
||||||
|
<button type="button" class="btn btn-sm" style="background:#475569" onclick="document.getElementById('upd-modal').style.display='none'">{tr("Отмена","Cancel")}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{tr("Обновить сейчас","Update now")}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>'''
|
||||||
|
|
||||||
|
|
||||||
def render(content):
|
def render(content):
|
||||||
return PAGE.replace("CONTENT", content)
|
return PAGE.replace("CONTENT", update_banner() + content)
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@@ -2294,6 +2610,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 +2938,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 +2951,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>"""
|
||||||
@@ -2716,6 +3054,7 @@ def settings_page():
|
|||||||
_out = tr("WARP настройки сохранены.", "WARP settings saved.")
|
_out = tr("WARP настройки сохранены.", "WARP settings saved.")
|
||||||
elif _act == "update":
|
elif _act == "update":
|
||||||
_out = run_update(request.form.get("target", "").strip() or "main")
|
_out = run_update(request.form.get("target", "").strip() or "main")
|
||||||
|
_upd_cache["ts"] = 0
|
||||||
elif _act == "check":
|
elif _act == "check":
|
||||||
_out = run_update("--check")
|
_out = run_update("--check")
|
||||||
elif _act == "warp_enable":
|
elif _act == "warp_enable":
|
||||||
@@ -2753,6 +3092,8 @@ def settings_page():
|
|||||||
ok, _out = run_server_script("phobos-domain.sh", "remove")
|
ok, _out = run_server_script("phobos-domain.sh", "remove")
|
||||||
elif _act == "endpoints_save":
|
elif _act == "endpoints_save":
|
||||||
ok, _out = save_client_endpoints(request.form)
|
ok, _out = save_client_endpoints(request.form)
|
||||||
|
elif _act == "dns_save":
|
||||||
|
ok, _out = save_client_dns(request.form)
|
||||||
else:
|
else:
|
||||||
ok, _out = run_server_script("phobos-guard.sh", _act.split("_", 1)[1], timeout=300)
|
ok, _out = run_server_script("phobos-guard.sh", _act.split("_", 1)[1], timeout=300)
|
||||||
cls = "alert-ok" if ok else "alert-error"
|
cls = "alert-ok" if ok else "alert-error"
|
||||||
@@ -2861,6 +3202,15 @@ def settings_page():
|
|||||||
<p style="font-size:.8em;color:var(--muted);margin-top:6px">{tr("Отключить: phobos-cascade disable · статус: phobos-cascade status. iOS/обычный WireGuard (порт 51820) через каскад не идёт.","Disable: phobos-cascade disable · status: phobos-cascade status. iOS/plain WireGuard (port 51820) does not go through the cascade.")}</p>
|
<p style="font-size:.8em;color:var(--muted);margin-top:6px">{tr("Отключить: phobos-cascade disable · статус: phobos-cascade status. iOS/обычный WireGuard (порт 51820) через каскад не идёт.","Disable: phobos-cascade disable · status: phobos-cascade status. iOS/plain WireGuard (port 51820) does not go through the cascade.")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{tr("DNS для клиентов","Client DNS")} {hlp("DNS-серверы в конфиге клиента (строка DNS = в [Interface]). Пусто = 1.1.1.1, 8.8.8.8. Новые клиенты получают сразу; уже выданным — заново открыть ссылку/QR. Роутеры Keenetic раздают в сеть свой DNS. Если у клиента включён firewall (🛡) — добавьте эти адреса в разрешённые.", "DNS servers in the client config (DNS = line in [Interface]). Empty = 1.1.1.1, 8.8.8.8. New clients get it at once; existing ones must reopen the link/QR. Keenetic routers hand out their own DNS to the LAN. If a client has the firewall (🛡) on, add these addresses to its allow list.")}</h2>
|
||||||
|
<form method="post" class="form-row">
|
||||||
|
<input type="hidden" name="action" value="dns_save">
|
||||||
|
<input name="client_dns" value="{_html_attr(client_dns())}" placeholder="1.1.1.1, 8.8.8.8" style="width:260px;padding:5px 8px">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">{tr("Сохранить","Save")}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{tr("Защита сервера (сторож)","Server protection (guard)")} {hlp("Блокирует подсети зондов и сканеров РКН до того, как они дойдут до сервера (nftables). Список IP — из проекта Loorrr293/Auto_IPtables, обновляется раз в сутки. Частные сети (LAN, туннель) никогда не блокируются.", "Drops RKN probe/scanner subnets before they reach the server (nftables). IP list from Loorrr293/Auto_IPtables, refreshed daily. Private networks (LAN, tunnel) are never blocked.")}</h2>
|
<h2>{tr("Защита сервера (сторож)","Server protection (guard)")} {hlp("Блокирует подсети зондов и сканеров РКН до того, как они дойдут до сервера (nftables). Список IP — из проекта Loorrr293/Auto_IPtables, обновляется раз в сутки. Частные сети (LAN, туннель) никогда не блокируются.", "Drops RKN probe/scanner subnets before they reach the server (nftables). IP list from Loorrr293/Auto_IPtables, refreshed daily. Private networks (LAN, tunnel) are never blocked.")}</h2>
|
||||||
<p>{tr("Статус","Status")}: <b style="color:{'#16a34a' if guard.get("enabled") else '#f59e0b'}">{tr("включён","enabled") if guard.get("enabled") else tr("выключен","disabled")}</b>
|
<p>{tr("Статус","Status")}: <b style="color:{'#16a34a' if guard.get("enabled") else '#f59e0b'}">{tr("включён","enabled") if guard.get("enabled") else tr("выключен","disabled")}</b>
|
||||||
@@ -2991,6 +3341,7 @@ def settings_page():
|
|||||||
<tr><td>{tr("WARP+ key","WARP+ key")}</td><td>{tr("сохранён","saved") if warp_license_saved else tr("не задан","not set")}</td></tr>
|
<tr><td>{tr("WARP+ key","WARP+ key")}</td><td>{tr("сохранён","saved") if warp_license_saved else tr("не задан","not set")}</td></tr>
|
||||||
<tr><td>{tr("Custom profile","Custom profile")}</td><td>{tr("сохранён","saved") if custom_profile_saved else tr("не задан","not set")}</td></tr>
|
<tr><td>{tr("Custom profile","Custom profile")}</td><td>{tr("сохранён","saved") if custom_profile_saved else tr("не задан","not set")}</td></tr>
|
||||||
<tr><td>{tr("Egress IPv4","Egress IPv4")}</td><td>{warp_egress or '—'}</td></tr>
|
<tr><td>{tr("Egress IPv4","Egress IPv4")}</td><td>{warp_egress or '—'}</td></tr>
|
||||||
|
<tr><td>Handshake</td><td>{(str(warp.get("handshake_age")) + tr(" с назад", "s ago")) if warp.get("handshake_age", -1) >= 0 else tr("нет — UDP до Cloudflare не проходит", "none — UDP to Cloudflare blocked")}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<form method="post" style="margin-top:10px">
|
<form method="post" style="margin-top:10px">
|
||||||
<input type="hidden" name="action" value="warp_save">
|
<input type="hidden" name="action" value="warp_save">
|
||||||
@@ -3376,13 +3727,12 @@ def download_apk():
|
|||||||
@auth_required
|
@auth_required
|
||||||
def client_conf_download(cid, mode):
|
def client_conf_download(cid, mode):
|
||||||
m = "ios" if mode == "ios" else "android"
|
m = "ios" if mode == "ios" else "android"
|
||||||
if m == "android":
|
|
||||||
return ("Android/PhobosWG uses phobos:// QR/link import; .conf import is only valid for iOS/plain WireGuard.", 400)
|
|
||||||
conf, _link = build_phone_config(cid, m)
|
conf, _link = build_phone_config(cid, m)
|
||||||
if conf is None:
|
if conf is None:
|
||||||
return ("client not found", 404)
|
return ("client not found", 404)
|
||||||
if m == "android":
|
if m == "android":
|
||||||
conf = _pad_conf_with_none(conf)
|
# raw .conf carries no "= none" placeholders (spec 2.3: padding is for the link only)
|
||||||
|
conf = "\n".join(ln for ln in conf.split("\n") if ln.partition("=")[2].strip().lower() != "none")
|
||||||
return (conf, 200, {
|
return (conf, 200, {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
"Content-Disposition": 'attachment; filename="%s-%s.conf"' % (cid, m),
|
"Content-Disposition": 'attachment; filename="%s-%s.conf"' % (cid, m),
|
||||||
@@ -3423,7 +3773,9 @@ def client_phone(cid, mode):
|
|||||||
f'<p style="font-size:.8em;color:var(--muted)">{tr("Поставь любое из двух приложений и открой ссылку ниже на телефоне — профиль импортируется сам.","Install either app and open the link below on the phone — the profile imports itself.")}</p>')
|
f'<p style="font-size:.8em;color:var(--muted)">{tr("Поставь любое из двух приложений и открой ссылку ниже на телефоне — профиль импортируется сам.","Install either app and open the link below on the phone — the profile imports itself.")}</p>')
|
||||||
extra = (client_btn + apk_btn +
|
extra = (client_btn + apk_btn +
|
||||||
f'<p>{tr("phobos:// ссылка — импорт в PCA Phobos VPN или PhobosWG:", "phobos:// link — import into PCA Phobos VPN or PhobosWG:")} <a href="{link}">{tr("открыть", "open")}</a></p>'
|
f'<p>{tr("phobos:// ссылка — импорт в PCA Phobos VPN или PhobosWG:", "phobos:// link — import into PCA Phobos VPN or PhobosWG:")} <a href="{link}">{tr("открыть", "open")}</a></p>'
|
||||||
f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>')
|
f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>'
|
||||||
|
f'<p style="font-size:.85em"><a href="/client/{cid}/conf/android">⬇ {tr("Файл .conf — для «Импорт из файла» в PCA Phobos VPN", ".conf file — for “Import from file” in PCA Phobos VPN")}</a>. '
|
||||||
|
f'{tr("Ссылка выше подходит и для HydraRoute (формат phobos:// автора Phobos).", "The link above also works in HydraRoute (upstream phobos:// format).")}</p>')
|
||||||
else:
|
else:
|
||||||
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
|
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
|
||||||
extra = (f'<p><a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener" class="btn btn-primary" style="background:#0ea5e9">📥 {tr("WireGuard в App Store","WireGuard on the App Store")}</a> <a href="/client/{cid}/conf/ios" class="btn" style="background:#7c3aed">⬇ {tr("Скачать .conf","Download .conf")}</a></p>'
|
extra = (f'<p><a href="https://apps.apple.com/app/wireguard/id1441195209" target="_blank" rel="noopener" class="btn btn-primary" style="background:#0ea5e9">📥 {tr("WireGuard в App Store","WireGuard on the App Store")}</a> <a href="/client/{cid}/conf/ios" class="btn" style="background:#7c3aed">⬇ {tr("Скачать .conf","Download .conf")}</a></p>'
|
||||||
@@ -4389,6 +4741,7 @@ def servers_page():
|
|||||||
Peers: <b style="color:var(--text)">{peers_val}</b> Assigned: <b style="color:var(--text)">{assigned}</b> Ports: {ports}<br>
|
Peers: <b style="color:var(--text)">{peers_val}</b> Assigned: <b style="color:var(--text)">{assigned}</b> Ports: {ports}<br>
|
||||||
<span title="Канал / Channel (rx+tx)">↕ <b style="color:var(--text)">{mbps_disp}</b></span><br>
|
<span title="Канал / Channel (rx+tx)">↕ <b style="color:var(--text)">{mbps_disp}</b></span><br>
|
||||||
{speed_line}
|
{speed_line}
|
||||||
|
<br>{google_geo_html(ip)}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" style="margin-top:8px" onsubmit="this.querySelector('button').textContent='⏳ ~20 s…'">
|
<form method="post" style="margin-top:8px" onsubmit="this.querySelector('button').textContent='⏳ ~20 s…'">
|
||||||
<input type="hidden" name="action" value="speedtest"><input type="hidden" name="ip" value="{ip}">
|
<input type="hidden" name="action" value="speedtest"><input type="hidden" name="ip" value="{ip}">
|
||||||
@@ -4479,6 +4832,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>
|
||||||
@@ -4498,6 +4871,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>
|
||||||
@@ -4508,6 +4882,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">
|
||||||
@@ -4527,7 +4902,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">
|
||||||
@@ -4546,7 +4921,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">
|
||||||
@@ -4554,11 +4929,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__":
|
||||||
|
|||||||
51
docs/DNS.md
Normal file
51
docs/DNS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# DNS для клиентов и свой AdGuard Home
|
||||||
|
|
||||||
|
## Какие DNS получают клиенты
|
||||||
|
|
||||||
|
По умолчанию в конфиге клиента (секция `[Interface]`) стоит строка
|
||||||
|
|
||||||
|
```
|
||||||
|
DNS = 1.1.1.1, 8.8.8.8
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть Cloudflare и Google.
|
||||||
|
|
||||||
|
## Где посмотреть и поменять
|
||||||
|
|
||||||
|
**В панели:** «Настройки» → «DNS для клиентов». Впишите адреса через запятую. Если поле пустое, используются 1.1.1.1 и 8.8.8.8.
|
||||||
|
|
||||||
|
- Новые клиенты получают новый DNS сразу.
|
||||||
|
- Уже выданным клиентам достаточно заново открыть ссылку или QR, пересоздавать клиента не нужно.
|
||||||
|
- Значение хранится в `/opt/Phobos/server/server.env` в строке `CLIENT_DNS=...`, без пробелов.
|
||||||
|
|
||||||
|
**Вручную, только для одного клиента:** исправьте строку `DNS =` в его `.conf` и заново импортируйте конфиг.
|
||||||
|
|
||||||
|
**Роутеры Keenetic** раздают устройствам в домашней сети свой собственный DNS, строка из конфига на них не влияет.
|
||||||
|
|
||||||
|
**Firewall клиента (🛡).** Если клиенту задан список разрешённых адресов, всё остальное блокируется, и запросы к DNS тоже. Добавьте в разрешённые адреса DNS-серверов, например `1.1.1.1:53/udp, 8.8.8.8:53/udp`.
|
||||||
|
|
||||||
|
## Свой AdGuard Home на том же VPS
|
||||||
|
|
||||||
|
> `127.0.0.1` в конфиг клиента прописывать **нельзя**. Строку `DNS =` выполняет устройство клиента, и `127.0.0.1` для него — это оно само, а не сервер.
|
||||||
|
|
||||||
|
Указывать нужно адрес сервера **внутри туннеля**. Это первый адрес подсети WG, его видно в строке `Address` файла `/etc/wireguard/wg0.conf` на сервере, например `10.25.11.1`. Закрытый наружу 53 порт ничему не мешает: запросы идут внутри туннеля через интерфейс `wg0`.
|
||||||
|
|
||||||
|
1. **AGH должен слушать адрес wg0.** В `AdGuardHome.yaml`, в разделе `dns.bind_hosts`, должен быть `10.25.11.1` или `0.0.0.0`. Если там только `127.0.0.1`, добавьте адрес wg0 и перезапустите AGH.
|
||||||
|
2. **Откройте 53 порт только из туннеля** (нужно, если входящие соединения на сервере закрыты):
|
||||||
|
```bash
|
||||||
|
iptables -I INPUT -i wg0 -p udp --dport 53 -j ACCEPT
|
||||||
|
iptables -I INPUT -i wg0 -p tcp --dport 53 -j ACCEPT
|
||||||
|
```
|
||||||
|
Чтобы правило пережило перезагрузку, сохраните его, например через `netfilter-persistent save`.
|
||||||
|
3. **Пропишите этот адрес клиентам:** панель → «Настройки» → «DNS для клиентов» → `10.25.11.1`.
|
||||||
|
4. Если у клиента включён firewall 🛡, добавьте в разрешённые `10.25.11.1:53/udp`.
|
||||||
|
|
||||||
|
Проверка с устройства клиента при включённом VPN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nslookup ya.ru 10.25.11.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрос должен появиться в журнале запросов AGH.
|
||||||
|
|
||||||
|
**Если серверов несколько.** Клиент может переключиться на другой сервер, а адрес DNS в конфиге один для всех. Поэтому либо ставьте AGH на каждый сервер так, чтобы он слушал адрес wg0 этого сервера, либо оставьте общедоступные DNS.
|
||||||
29
docs/WARP.md
Normal file
29
docs/WARP.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# WARP: проверка и типичные проблемы
|
||||||
|
|
||||||
|
WARP включается в панели: «Настройки» → WARP. Режим egress выпускает исходящий трафик клиентов в интернет через Cloudflare: всех клиентов или только отмеченных.
|
||||||
|
|
||||||
|
## Как понять, что WARP реально работает
|
||||||
|
|
||||||
|
В карточке WARP есть строка **Handshake**:
|
||||||
|
|
||||||
|
- **«N с назад»** — связь с Cloudflare есть.
|
||||||
|
- **«нет — UDP до Cloudflare не проходит»** — интерфейс поднят, но Cloudflare не отвечает. Трафик клиентов через WARP идти не будет.
|
||||||
|
|
||||||
|
Проверка с сервера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wg show warp latest-handshakes
|
||||||
|
curl --interface warp -m 6 https://www.cloudflare.com/cdn-cgi/trace | grep warp= # ожидается warp=on
|
||||||
|
phobos-warp status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что делает панель сама
|
||||||
|
|
||||||
|
- **Ищет рабочий адрес Cloudflare.** При включении перебирает адреса и порты (2408 / 500 / 1701 / 4500) и записывает в конфиг первый, с которым появился handshake. Если не подошёл ни один, это занимает около минуты.
|
||||||
|
- **Исправляет размер пакетов.** У WARP MTU 1280, у клиентов 1420. Без поправки крупные TCP-пакеты терялись, и интернет у клиентов работал «через раз». Теперь сервер автоматически уменьшает размер TCP-пакетов под WARP (правило MSS-clamp).
|
||||||
|
|
||||||
|
## «У клиентов нет интернета», IP Cloudflare показывается через раз
|
||||||
|
|
||||||
|
1. Обновите панель до версии с этими исправлениями («Версия и обновления»), затем выключите и снова включите WARP.
|
||||||
|
2. Посмотрите строку Handshake. Если там «нет», значит UDP до Cloudflare с этого VPS режется. Обычно так бывает на VPS в РФ, где может не проходить даже регистрация WARP. В этом случае держите WARP выключенным или используйте его на сервере за рубежом.
|
||||||
|
3. Если включён firewall клиента (🛡), проверьте, что в разрешённых есть DNS-серверы. См. [DNS.md](DNS.md).
|
||||||
21
install.sh
21
install.sh
@@ -42,6 +42,7 @@ PY
|
|||||||
fi
|
fi
|
||||||
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
# Подсеть клиентов: env SERVER_WG_IPV4_NETWORK или вопрос в терминале (Enter = 10.25.0.0/16)
|
||||||
if [ -z "${SERVER_WG_IPV4_NETWORK:-}" ] && [ -r /dev/tty ] && [ -t 1 ]; then
|
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
|
printf 'Подсеть VPN-клиентов [10.25.0.0/16]: ' > /dev/tty
|
||||||
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
read -r -t 60 SERVER_WG_IPV4_NETWORK < /dev/tty || true
|
||||||
fi
|
fi
|
||||||
@@ -49,9 +50,16 @@ SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
|||||||
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
import ipaddress, sys
|
import ipaddress, sys
|
||||||
try:
|
try:
|
||||||
net = ipaddress.ip_network(sys.argv[1].strip(), strict=False)
|
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
# «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:
|
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)")
|
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||||
print(net)
|
print(net)
|
||||||
@@ -60,7 +68,7 @@ PY
|
|||||||
# Источник: 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}"
|
||||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
|
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||||
CHANNEL="${CHANNEL:-stable}"
|
CHANNEL="${CHANNEL:-stable}"
|
||||||
case "$CHANNEL" in
|
case "$CHANNEL" in
|
||||||
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
stable|main|"") PCA_BRANCH="${PCA_BRANCH:-main}";;
|
||||||
@@ -363,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"
|
||||||
|
|||||||
@@ -460,14 +460,15 @@ deploy_health_monitor() {
|
|||||||
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
|
# (vixie, PROCS=cron) на Keenetic задачи не выполнял — заменяем (01.11.2026)
|
||||||
local I=/opt/etc/init.d/S10cron
|
local I=/opt/etc/init.d/S10cron
|
||||||
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
|
if [ ! -x $I ] || grep -q "^PROCS=cron$" $I; then
|
||||||
$I stop >/dev/null 2>&1
|
[ -x $I ] && $I stop </dev/null >/dev/null 2>&1
|
||||||
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
|
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
|
||||||
chmod +x $I
|
chmod +x $I
|
||||||
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
|
mkdir -p /opt/var/spool/cron/crontabs
|
||||||
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
# 01.11.2026: демоны cron на Keenetic (busybox crond, Entware cron) читают crontab
|
||||||
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
# пользователя root, а не /opt/etc/crontab — туда писать бесполезно
|
||||||
sed -i "/phobos-health\|phobos-pull/d" /opt/etc/crontab 2>/dev/null
|
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 -
|
(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)"
|
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
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ PY
|
|||||||
[Interface]
|
[Interface]
|
||||||
PrivateKey = $priv_key
|
PrivateKey = $priv_key
|
||||||
Address = $addr_str
|
Address = $addr_str
|
||||||
DNS = 1.1.1.1, 8.8.8.8
|
DNS = ${CLIENT_DNS:-1.1.1.1, 8.8.8.8}
|
||||||
MTU = 1420
|
MTU = 1420
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
|
|||||||
10
promote.sh
10
promote.sh
@@ -3,9 +3,9 @@
|
|||||||
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
# PCA Phobos — promote / sync каналов (maintainer-инструмент)
|
||||||
#
|
#
|
||||||
# Каналы и репозитории:
|
# Каналы и репозитории:
|
||||||
# stable -> публичный andrey271192/PCA_Phobos ветка main
|
# stable -> публичный adleibzon-rgb/PCA_Phobos ветка main
|
||||||
# beta -> приватный andrey271192/PCA_Phobos-dev ветка beta
|
# beta -> приватный adleibzon-rgb/PCA_Phobos-dev ветка beta
|
||||||
# dev -> приватный andrey271192/PCA_Phobos-dev ветка dev
|
# dev -> приватный adleibzon-rgb/PCA_Phobos-dev ветка dev
|
||||||
#
|
#
|
||||||
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
# Поток фич — строго в одну сторону: dev -> beta -> stable
|
||||||
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
# Хотфикс в stable -> forward-port в beta и dev (чтоб не потерялся).
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
set -e
|
set -e
|
||||||
GH_TOKEN="${GH_TOKEN:-}"
|
GH_TOKEN="${GH_TOKEN:-}"
|
||||||
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
[ -n "$GH_TOKEN" ] || { echo "Нужен GH_TOKEN (env)."; exit 1; }
|
||||||
PUB="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos.git"
|
PUB="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos.git"
|
||||||
PRIV="https://andrey271192:${GH_TOKEN}@github.com/andrey271192/PCA_Phobos-dev.git"
|
PRIV="https://adleibzon-rgb:${GH_TOKEN}@github.com/adleibzon-rgb/PCA_Phobos-dev.git"
|
||||||
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
WORK="${PCA_WORK:-/tmp/pca-promote-work}"
|
||||||
|
|
||||||
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
repo_for() { case "$1" in main) echo "$PUB";; beta|dev) echo "$PRIV";; esac; }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ post_up = (
|
|||||||
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; "
|
||||||
f"ip route replace {subnet} dev wg0 table {table}; "
|
f"ip route replace {subnet} dev wg0 table {table}; "
|
||||||
f"ip route replace default dev %i table {table}; "
|
f"ip route replace default dev %i table {table}; "
|
||||||
|
"iptables -t mangle -C FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || "
|
||||||
|
"iptables -t mangle -A FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu; "
|
||||||
"iptables -C FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -i wg0 -o %i -j ACCEPT; "
|
"iptables -C FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -i wg0 -o %i -j ACCEPT; "
|
||||||
"iptables -C FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || "
|
"iptables -C FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || "
|
||||||
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
|
"iptables -I FORWARD 1 -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; "
|
||||||
@@ -203,6 +205,7 @@ post_up = (
|
|||||||
post_down = (
|
post_down = (
|
||||||
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
|
f"ip route del {subnet} dev wg0 table {table} 2>/dev/null || true; "
|
||||||
f"ip route del default dev %i table {table} 2>/dev/null || true; "
|
f"ip route del default dev %i table {table} 2>/dev/null || true; "
|
||||||
|
"iptables -t mangle -D FORWARD -o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true; "
|
||||||
"iptables -D FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || true; "
|
"iptables -D FORWARD -i wg0 -o %i -j ACCEPT 2>/dev/null || true; "
|
||||||
"iptables -D FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
|
"iptables -D FORWARD -i %i -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true; "
|
||||||
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
|
f"iptables -t nat -D POSTROUTING -s {subnet} -o %i -j MASQUERADE 2>/dev/null || true"
|
||||||
@@ -234,6 +237,7 @@ clear_egress() {
|
|||||||
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
systemctl disable --now "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||||
ip route del "$(wg_subnet)" dev wg0 table "$WARP_TABLE" 2>/dev/null || true
|
ip route del "$(wg_subnet)" dev wg0 table "$WARP_TABLE" 2>/dev/null || true
|
||||||
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
|
ip route del default dev "$WARP_IF" table "$WARP_TABLE" 2>/dev/null || true
|
||||||
|
iptables -t mangle -D FORWARD -o "$WARP_IF" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null || true
|
||||||
iptables -D FORWARD -i wg0 -o "$WARP_IF" -j ACCEPT 2>/dev/null || true
|
iptables -D FORWARD -i wg0 -o "$WARP_IF" -j ACCEPT 2>/dev/null || true
|
||||||
iptables -D FORWARD -i "$WARP_IF" -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
iptables -D FORWARD -i "$WARP_IF" -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||||
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
|
iptables -t nat -D POSTROUTING -s "$(wg_subnet)" -o "$WARP_IF" -j MASQUERADE 2>/dev/null || true
|
||||||
@@ -356,6 +360,35 @@ relay_enabled() {
|
|||||||
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
iptables-save 2>/dev/null | grep -q "$RELAY_TAG_PREFIX"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warp_handshake_age() {
|
||||||
|
local ts
|
||||||
|
ts="$(wg show "$WARP_IF" latest-handshakes 2>/dev/null | awk 'NR==1{print $2}')"
|
||||||
|
[ -n "$ts" ] && [ "$ts" != "0" ] && echo $(( $(date +%s) - ts )) || echo -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cloudflare UDP to engage.cloudflareclient.com:2408 is often cut on the way:
|
||||||
|
# try the profile endpoint first, then known WARP IP:port pairs, keep the first with a handshake.
|
||||||
|
pick_warp_endpoint() {
|
||||||
|
local pub ep cur i age
|
||||||
|
pub="$(wg show "$WARP_IF" peers 2>/dev/null | head -1)"
|
||||||
|
[ -n "$pub" ] || return 0
|
||||||
|
cur="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" | tr -d ' ')"
|
||||||
|
for ep in "$cur" 162.159.192.1:2408 162.159.193.1:2408 162.159.192.1:500 162.159.192.1:1701 162.159.192.1:4500 162.159.195.1:2408 188.114.97.1:2408; do
|
||||||
|
[ -n "$ep" ] || continue
|
||||||
|
wg set "$WARP_IF" peer "$pub" endpoint "$ep" 2>/dev/null || continue
|
||||||
|
for i in 1 2 3 4 5 6; do
|
||||||
|
ping -c1 -W1 -I "$WARP_IF" 1.1.1.1 >/dev/null 2>&1 || true
|
||||||
|
age="$(warp_handshake_age)"
|
||||||
|
if [ "$age" -ge 0 ] && [ "$age" -lt 30 ]; then
|
||||||
|
[ "$ep" = "$cur" ] || sed -i "s|^Endpoint *=.*|Endpoint = ${ep}|" "$WARP_CONF"
|
||||||
|
echo "WARP endpoint: $ep" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo "WARP: no handshake on any endpoint (UDP to Cloudflare blocked?)" >&2
|
||||||
|
}
|
||||||
|
|
||||||
enable_egress() {
|
enable_egress() {
|
||||||
local profile_source profile_path existing_clients
|
local profile_source profile_path existing_clients
|
||||||
load_env
|
load_env
|
||||||
@@ -368,6 +401,7 @@ enable_egress() {
|
|||||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
systemctl enable "wg-quick@${WARP_IF}" >/dev/null 2>&1 || true
|
||||||
systemctl restart "wg-quick@${WARP_IF}"
|
systemctl restart "wg-quick@${WARP_IF}"
|
||||||
|
pick_warp_endpoint
|
||||||
clear_legacy_subnet_rule
|
clear_legacy_subnet_rule
|
||||||
reapply_client_rules "$existing_clients"
|
reapply_client_rules "$existing_clients"
|
||||||
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
save_state "egress" "$profile_source" "" "" "" "$existing_clients"
|
||||||
@@ -436,7 +470,7 @@ client_disable() {
|
|||||||
status_json() {
|
status_json() {
|
||||||
load_env
|
load_env
|
||||||
load_state
|
load_state
|
||||||
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on
|
local subnet service configured endpoint pubkey egress enabled mode profile_source relay_on handshake=-1
|
||||||
subnet="$(wg_subnet)"
|
subnet="$(wg_subnet)"
|
||||||
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
service="$(systemctl is-active "wg-quick@${WARP_IF}" 2>/dev/null || true)"
|
||||||
mode="${STATE_MODE:-}"
|
mode="${STATE_MODE:-}"
|
||||||
@@ -453,6 +487,7 @@ status_json() {
|
|||||||
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || true)"
|
endpoint="$(awk -F'= *' '/^Endpoint *=/{print $2; exit}' "$WARP_CONF" 2>/dev/null || true)"
|
||||||
pubkey="$(wg show "${WARP_IF}" public-key 2>/dev/null || true)"
|
pubkey="$(wg show "${WARP_IF}" public-key 2>/dev/null || true)"
|
||||||
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
|
egress="$(curl -4 --interface "${WARP_IF}" -fsS --max-time 6 https://api.ipify.org 2>/dev/null || true)"
|
||||||
|
handshake="$(warp_handshake_age)"
|
||||||
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
[ -n "$profile_source" ] || profile_source="$( [ -s "$CUSTOM_PROFILE" ] && echo custom || echo free )"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -469,9 +504,9 @@ status_json() {
|
|||||||
enabled=true
|
enabled=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" <<'PY'
|
python3 - "$enabled" "$configured" "$service" "$subnet" "$endpoint" "$pubkey" "$egress" "$mode" "$profile_source" "${STATE_RELAY_PORTS:-}" "${STATE_RELAY_TARGET:-}" "${STATE_RELAY_TARGET_IP:-}" "$( [ -s "$CUSTOM_PROFILE" ] && echo true || echo false )" "$relay_on" "$( [ -s "$LICENSE_FILE" ] && echo true || echo false )" "${STATE_CLIENTS:-}" "$handshake" <<'PY'
|
||||||
import json, sys
|
import json, sys
|
||||||
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv = sys.argv[1:]
|
enabled, configured, service, subnet, endpoint, pubkey, egress, mode, profile_source, relay_ports, relay_target, relay_target_ip, custom_saved, relay_on, license_saved, clients_csv, handshake = sys.argv[1:]
|
||||||
if relay_on == "true" and service != "active":
|
if relay_on == "true" and service != "active":
|
||||||
service = "relay"
|
service = "relay"
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
@@ -484,6 +519,7 @@ print(json.dumps({
|
|||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"public_key": pubkey,
|
"public_key": pubkey,
|
||||||
"egress_ipv4": egress,
|
"egress_ipv4": egress,
|
||||||
|
"handshake_age": int(handshake),
|
||||||
"relay_enabled": relay_on == "true",
|
"relay_enabled": relay_on == "true",
|
||||||
"relay_ports": relay_ports,
|
"relay_ports": relay_ports,
|
||||||
"relay_target": relay_target,
|
"relay_target": relay_target,
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ SERVER_WG_IPV4_NETWORK="${SERVER_WG_IPV4_NETWORK:-10.25.0.0/16}"
|
|||||||
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
SERVER_WG_IPV4_NETWORK="$(python3 - "$SERVER_WG_IPV4_NETWORK" <<'PY'
|
||||||
import ipaddress, sys
|
import ipaddress, sys
|
||||||
try:
|
try:
|
||||||
net = ipaddress.ip_network(sys.argv[1].strip(), strict=False)
|
net = ipaddress.ip_network(sys.argv[1].strip(), strict=True)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sys.exit("ERROR: bad subnet " + sys.argv[1])
|
# «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:
|
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)")
|
sys.exit("ERROR: subnet must be private IPv4, /8../29 (e.g. 10.25.1.0/24)")
|
||||||
print(net)
|
print(net)
|
||||||
@@ -309,7 +316,7 @@ RAW="${PCA_RAW_BASE:-https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/${PCA_BRA
|
|||||||
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
PANEL_BOOTSTRAP="${MAIN_PROTO}://${MAIN_SERVER}:${MAIN_PORT}/server/api.py"
|
||||||
curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
|
curl -kfsSL "$PANEL_BOOTSTRAP" -o "$PHOBOS_DIR/server/api.py" \
|
||||||
|| curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
|
|| curl -fsSL "$RAW/server/api.py" -o "$PHOBOS_DIR/server/api.py" \
|
||||||
|| curl -fsSL "https://raw.githubusercontent.com/andrey271192/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
|| curl -fsSL "https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/${PCA_BRANCH}/server/api.py" -o "$PHOBOS_DIR/server/api.py"
|
||||||
[ -s "$PHOBOS_DIR/server/api.py" ] || { echo "ERROR: api.py fetch failed"; exit 1; }
|
[ -s "$PHOBOS_DIR/server/api.py" ] || { echo "ERROR: api.py fetch failed"; exit 1; }
|
||||||
|
|
||||||
cat > /etc/systemd/system/phobos-api.service << EOF
|
cat > /etc/systemd/system/phobos-api.service << EOF
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# PCA Phobos — полное удаление сервера (primary / panel node)
|
# PCA Phobos — полное удаление сервера (primary / panel node)
|
||||||
#
|
#
|
||||||
# bash <(curl -fsSL https://raw.githubusercontent.com/andrey271192/PCA_Phobos/main/uninstall.sh)
|
# bash <(curl -fsSL https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos/main/uninstall.sh)
|
||||||
#
|
#
|
||||||
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
# Останавливает и удаляет: панель, обфускатор-сервисы, WireGuard wg0,
|
||||||
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
# nginx-сайт, watchdog-cron, бинарники, iptables-правила, каталоги Phobos.
|
||||||
@@ -21,8 +21,11 @@ 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, "да"
|
||||||
|
# no tr: in C locale it mangles Cyrillic bytes
|
||||||
|
ans=${ans//$'\r'/}; ans=${ans//[[:space:]]/}
|
||||||
|
case "$ans" in yes|Yes|YES|y|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)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ set -e
|
|||||||
PHOBOS_DIR=/opt/Phobos
|
PHOBOS_DIR=/opt/Phobos
|
||||||
PANEL_DIR=/opt/phobos-panel
|
PANEL_DIR=/opt/phobos-panel
|
||||||
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
PCA_GIT="${PCA_GIT:-https://git.bobyrev.com/PCA/PCA_Phobos}"
|
||||||
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/andrey271192/PCA_Phobos}"
|
GH_RAW="${GH_RAW:-https://raw.githubusercontent.com/adleibzon-rgb/PCA_Phobos}"
|
||||||
BACKUPS="$PHOBOS_DIR/updates"
|
BACKUPS="$PHOBOS_DIR/updates"
|
||||||
VERFILE="$PANEL_DIR/.version"
|
VERFILE="$PANEL_DIR/.version"
|
||||||
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
|
CUR=$(cat "$VERFILE" 2>/dev/null || echo "unknown")
|
||||||
@@ -53,8 +53,9 @@ fetch_repo_file() { # $1=ref $2=repo_path $3=output ; Gitea first, GitHub fallb
|
|||||||
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
|
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;;
|
fi;;
|
||||||
esac
|
esac
|
||||||
curl -fsSL -m25 "$PCA_GIT/raw/$kind/$ref/$rp?cb=$(date +%s)" -o "$out" 2>/dev/null && return 0
|
# GitHub first (Gitea sits on one home IP and is cut for some providers, 09.33), Gitea fallback
|
||||||
curl -fsSL -m25 "$GH_RAW/$ref/$rp?cb=$(date +%s)" -o "$out"
|
curl -fsSL -m25 "$GH_RAW/$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"
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_repo_text() { # $1=ref $2=repo_path
|
fetch_repo_text() { # $1=ref $2=repo_path
|
||||||
@@ -69,6 +70,7 @@ fetch_repo_text() { # $1=ref $2=repo_path
|
|||||||
managed_files() {
|
managed_files() {
|
||||||
cat <<LIST
|
cat <<LIST
|
||||||
$PANEL_DIR/app.py|app.py
|
$PANEL_DIR/app.py|app.py
|
||||||
|
$PANEL_DIR/APPS|APPS
|
||||||
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
|
$PHOBOS_DIR/repo/server/scripts/lib-core.sh|overlay/lib-core.sh
|
||||||
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
|
$PHOBOS_DIR/repo/server/scripts/phobos-client.sh|overlay/phobos-client.sh
|
||||||
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
|
$PHOBOS_DIR/repo/client/templates/install-router.sh.template|overlay/install-router.sh.template
|
||||||
|
|||||||
Reference in New Issue
Block a user