Phone clients: panel generates phobos:// link + QR (Android) and plain WG + QR (iOS); installer qrcode dep + ALLOW_PLAIN_WG option

This commit is contained in:
phobos
2026-05-30 14:25:35 +03:00
parent 87882b0156
commit 54867442ae
3 changed files with 92 additions and 3 deletions

View File

@@ -148,3 +148,12 @@ PCA Phobos — это веб-панель и turnkey-инсталлятор по
- [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) (ClusterM) — обфускация WireGuard-трафика. Поддержать автора: [Boosty](https://boosty.to/cluster) ❤️ - [**wg-obfuscator**](https://github.com/ClusterM/wg-obfuscator) (ClusterM) — обфускация WireGuard-трафика. Поддержать автора: [Boosty](https://boosty.to/cluster) ❤️
Спасибо авторам за отличные инструменты. Спасибо авторам за отличные инструменты.
## Клиенты для телефона
В таблице клиентов у каждого клиента есть кнопки:
- 📱 **Android**`phobos://`-ссылка + QR для приложения **PhobosWG** (с обфускацией). Скан QR → импорт.
- 🍎 **iPhone / iOS** — обычный WireGuard-конфиг + QR для официального WireGuard (БЕЗ обфускации). Требует открытого порта 51820 на сервере (поставь `ALLOW_PLAIN_WG=1` при установке, либо удали правило `iptables ... 51820 ... DROP`).
> Для каждого устройства создавай **отдельного клиента** (свой ключ и IP). Один конфиг на двух устройствах = конфликт ключей.

75
app.py
View File

@@ -403,6 +403,49 @@ def generate_failover_conf_for_client(client_id):
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def build_phone_config(cid, mode="android"):
"""Assemble a phone client config from the client's files.
android = WireGuard + [instance] obfuscator -> phobos:// link (PhobosWG app).
ios = plain WireGuard (no obfuscation), Endpoint -> server:51820."""
import base64, urllib.parse
cdir = os.path.join(CLIENTS_DIR, cid)
wgpath = os.path.join(cdir, cid + ".conf")
instpath = os.path.join(cdir, "wg-obfuscator.conf")
if not os.path.exists(wgpath):
return None, None
wg = open(wgpath).read().rstrip()
if mode == "ios":
server_ip = SERVER_IP
try:
for ln in open(instpath):
if ln.strip().startswith("target"):
server_ip = ln.split("=", 1)[1].strip().split(":")[0]
break
except Exception:
pass
out = []
for ln in wg.split("\n"):
out.append("Endpoint = %s:51820" % server_ip if ln.strip().startswith("Endpoint") else ln)
return "\n".join(out) + "\n", None
inst = ""
if os.path.exists(instpath):
inst = open(instpath).read().strip()
conf = wg + "\n\n" + inst + "\n"
b64 = base64.urlsafe_b64encode(conf.encode()).decode().rstrip("=")
link = "phobos://" + b64 + "#" + urllib.parse.quote(cid)
return conf, link
def qr_datauri(data):
try:
import qrcode, io, base64
buf = io.BytesIO()
qrcode.make(data).save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return ""
def fanout_router_config(client_id): def fanout_router_config(client_id):
"""Push this client's failover.conf to every secondary server's agent so """Push this client's failover.conf to every secondary server's agent so
routers can PULL it through the tunnel (10.25.0.1:8444) — survives a public routers can PULL it through the tunnel (10.25.0.1:8444) — survives a public
@@ -960,6 +1003,8 @@ def clients_page():
<input type="hidden" name="client_id" value="{cid}"> <input type="hidden" name="client_id" value="{cid}">
<button class="btn btn-danger btn-sm" type="submit">{tr("Удалить", "Del")}</button> <button class="btn btn-danger btn-sm" type="submit">{tr("Удалить", "Del")}</button>
</form> </form>
<a href="/client/{cid}/phone/android" class="btn btn-sm" style="background:#16a34a" title="Android / PhobosWG">📱</a>
<a href="/client/{cid}/phone/ios" class="btn btn-sm" style="background:#475569" title="iPhone / iOS WireGuard">🍎</a>
{h_del} {h_del}
</td> </td>
</tr>""" </tr>"""
@@ -1210,6 +1255,36 @@ def sync_peer_to_all_servers(public_key, allowed_ips, action="add"):
sync_peer_to_server(srv, public_key, allowed_ips, action) sync_peer_to_server(srv, public_key, allowed_ips, action)
@app.route("/client/<cid>/phone/<mode>")
@auth_required
def client_phone(cid, mode):
if mode not in ("android", "ios"):
mode = "android"
conf, link = build_phone_config(cid, mode)
if conf is None:
return ("client not found", 404)
qr = qr_datauri(link if (mode == "android" and link) else conf)
if mode == "android":
title = tr("Android — PhobosWG (с обфускацией)", "Android — PhobosWG (obfuscated)")
extra = (f'<p>{tr("phobos:// ссылка — импорт в PhobosWG:", "phobos:// link — import into PhobosWG:")}</p>'
f'<textarea readonly onclick="this.select()" style="width:100%;height:90px;font-size:.75em">{link}</textarea>')
else:
title = tr("iPhone / iOS WireGuard (без обфускации)", "iPhone / iOS WireGuard (plain, no obfuscation)")
extra = (f'<p style="color:#fcd34d">{tr("⚠ Обычный WireGuard без маскировки (Endpoint → :51820). На сервере должен быть открыт порт 51820.", "⚠ Plain WireGuard, no obfuscation (Endpoint → :51820). Port 51820 must be open on the server.")}</p>')
html = f"""
<div class="container">
{nav('clients')}
<div class="card" style="text-align:center">
<h2>{cid}{title}</h2>
<img src="{qr}" alt="QR" style="width:300px;height:300px;background:#fff;padding:10px;border-radius:10px"><br>
<p style="margin-top:10px">{tr("Отсканируй QR в приложении или импортируй конфиг:", "Scan the QR in the app or import the config:")}</p>
{extra}
<textarea readonly onclick="this.select()" style="width:100%;height:250px;font-family:monospace;font-size:.78em">{conf}</textarea>
<p style="margin-top:10px"><a href="/clients" class="btn btn-sm">{tr("← назад", "← back")}</a></p>
</div></div>"""
return render(html)
@app.route("/api/router-config/<client_id>") @app.route("/api/router-config/<client_id>")
def router_config(client_id): def router_config(client_id):
"""NAT-friendly config pull: router fetches its own failover.conf. """NAT-friendly config pull: router fetches its own failover.conf.

View File

@@ -54,7 +54,7 @@ echo "[1/9] dependencies..."
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq apt-get update -qq
apt-get install -y -qq wireguard wireguard-tools iptables jq curl git \ apt-get install -y -qq wireguard wireguard-tools iptables jq curl git \
python3 python3-flask gunicorn nginx cron >/dev/null python3 python3-flask gunicorn nginx cron python3-qrcode >/dev/null
systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true systemctl enable cron -q 2>/dev/null || true; systemctl start cron 2>/dev/null || true
# ── 2. wg-obfuscator binary (Ground-Zerro) ── # ── 2. wg-obfuscator binary (Ground-Zerro) ──
@@ -107,8 +107,13 @@ systemctl restart wg-quick@wg0
# ── 5. obfuscator services (multi-port) ── # ── 5. obfuscator services (multi-port) ──
echo "[5/9] obfuscator services..." echo "[5/9] obfuscator services..."
OBF_KEY=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32) OBF_KEY=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 32)
iptables -C INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null \ # Block direct WG (force obfuscation) unless ALLOW_PLAIN_WG=1 (e.g. for iOS WireGuard)
|| iptables -A INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP if [ -z "${ALLOW_PLAIN_WG:-}" ]; then
iptables -C INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null \
|| iptables -A INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP
else
iptables -D INPUT -p udp --dport 51820 ! -s 127.0.0.1 -j DROP 2>/dev/null || true
fi
IFS=',' read -ra PORTS <<< "$OBF_PORTS" IFS=',' read -ra PORTS <<< "$OBF_PORTS"
for PORT in "${PORTS[@]}"; do for PORT in "${PORTS[@]}"; do
cat > "$PHOBOS_DIR/server/wg-obfuscator-${PORT}.conf" <<EOF cat > "$PHOBOS_DIR/server/wg-obfuscator-${PORT}.conf" <<EOF