diff --git a/APPS b/APPS
new file mode 100644
index 0000000..f2de949
--- /dev/null
+++ b/APPS
@@ -0,0 +1,3 @@
+Android (PCA Phobos VPN)=1.4.6
+iPhone (TestFlight)=1.4.5
+Windows=1.4.5-beta
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5dc8d52..b94c0f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+## 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`.
diff --git a/README.md b/README.md
index cfa826e..b9db340 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,7 @@ bash <(curl -fsSL https://git.bobyrev.com/PCA/PCA_Phobos/raw/branch/main/install
После установки панель напечатает адрес, логин, пароль и **API key** — сохрани их.
Полный список портов и что можно закрывать: [docs/PORTS.md](docs/PORTS.md).
+DNS клиентов и свой AdGuard Home: [docs/DNS.md](docs/DNS.md). WARP — проверка и проблемы: [docs/WARP.md](docs/WARP.md).
### Вторичный сервер (для failover / балансировки)
diff --git a/VERSION b/VERSION
index ac9f79c..079d7f6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.10
+1.4.11
diff --git a/app.py b/app.py
index 3e4a402..82d78a3 100644
--- a/app.py
+++ b/app.py
@@ -36,7 +36,7 @@ SECRET_FILE = f"{PANEL_DIR}/.secret_key"
WG_CONFIG = "/etc/wireguard/wg0.conf"
# Distribution: Gitea first (GitHub account is unavailable since 23.10.2026), GitHub fallback
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"
WARP_DIR = f"{PHOBOS_DIR}/server/warp"
WARP_CUSTOM_PROFILE_FILE = f"{WARP_DIR}/custom-profile.conf"
@@ -1071,15 +1071,15 @@ UPDATE_BIN = "/usr/local/bin/phobos-update"
def _update_repo_for_ref(ref):
ref = (ref or "").strip()
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"):
- 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()
if "beta" in version:
- return "andrey271192/PCA_Phobos-dev", "beta"
+ return "adleibzon-rgb/PCA_Phobos-dev", "beta"
if "dev" in version:
- return "andrey271192/PCA_Phobos-dev", "dev"
- return "andrey271192/PCA_Phobos", "main"
+ return "adleibzon-rgb/PCA_Phobos-dev", "dev"
+ return "adleibzon-rgb/PCA_Phobos", "main"
def _current_update_ref(arg):
@@ -1198,6 +1198,34 @@ def client_endpoint(ip):
_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):
@@ -1251,6 +1279,93 @@ def run_speedtest(ip):
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*\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")}: {tr("проверяется…", "checking…")}'
+ def b(v):
+ col = "#e66" if v in GOOGLE_CLOSED else "var(--text)"
+ return f'{v or "—"}'
+ 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'({ago} {tr("мин назад", "min ago")})')
+ if others:
+ html += f'
{tr("другие IPv4", "other IPv4")}: {others}'
+ bad = [v for v in (g.get("v4"), g.get("v6")) if v in GOOGLE_CLOSED]
+ if bad:
+ html += (f'
{tr("Google считает адрес", "Google treats the address as")} «{", ".join(bad)}» — '
+ f'{tr("Gemini и часть сервисов Google у клиентов не откроются", "Gemini and some Google services will not open for clients")}')
+ return html
+
+
LIMITS_FILE = f"{PHOBOS_DIR}/server/limits.conf"
@@ -1402,7 +1517,7 @@ def build_phone_config(cid, mode="android"):
instpath = os.path.join(cdir, "wg-obfuscator.conf")
if not os.path.exists(wgpath):
return None, None
- wg = open(wgpath).read().rstrip()
+ wg = _apply_client_dns(open(wgpath).read().rstrip())
if mode == "ios":
server_ip = SERVER_IP
try:
@@ -1436,7 +1551,8 @@ def build_phone_config(cid, mode="android"):
inst += "\n# pca-tcp = " + tcp_port
conf = wg + "\n\n" + inst + "\n"
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")
return payload, link
@@ -1916,7 +2032,7 @@ textarea::placeholder{color:var(--muted)}
CONTENT