feat: one-command install/uninstall + fix delete-ams in WebUI

- install.sh: интерактивный one-liner — ставит WG, ключи, скрипты,
  бот, WebUI, cron; сам поднимает RU-сервер если он чистый
- uninstall.sh: полная очистка (сервисы, конфиги, cron, /opt/kaskad)
  с подтверждением; опц. KASKAD_PURGE_KEYS=1 для затирания ключей
- webui: кнопка «удалить» для зарубежных серверов теперь явно красная
  (class danger.solid), для локального — « нельзя» вместо disabled
- webui: DELETE /api/ams/<id> переписан — портативный python-based
  peer-removal, явные ошибки, поддержка ?force=1
- README + docs/install.md: переписаны под обычного пользователя,
  one-command flow на первом плане, ручной — как fallback

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
andrey271192
2026-05-12 06:28:56 +03:00
parent b856909437
commit f6514e9bba
7 changed files with 768 additions and 185 deletions

View File

@@ -395,37 +395,58 @@ systemctl enable wg-quick@ru 2>&1 | tail -1
@app.route("/api/ams/<sid>", methods=["DELETE"])
@require_auth
def api_ams_remove(sid):
data = load_data()
target = next((a for a in data.get("ams_servers", []) if a["id"] == sid or a["host"] == sid), None)
if not target: return jsonify(error="не найден"), 404
if target.get("is_local"): return jsonify(error="нельзя удалить local"), 400
"""Удалить зарубежный (ам.) сервер из каскада.
Шаги:
1) на каждом RU-сервере убрать [Peer] с pubkey удаляемого ам.
2) обновить /etc/wireguard/ru-servers.json и разлить по остальным ам.
3) синхронизировать живой wg-интерфейс на RU через `wg set ... peer ... remove`
?force=1 — продолжить, даже если какой-то RU недоступен.
"""
force = request.args.get("force") in ("1", "true", "yes")
data = load_data()
target = next((a for a in data.get("ams_servers", [])
if a["id"] == sid or a["host"] == sid), None)
if not target:
return jsonify(error=f"ам. сервер '{sid}' не найден"), 404
if target.get("is_local"):
return jsonify(error="нельзя удалить сервер, на котором запущен WebUI"), 400
pubkey = target["pubkey"]
rus = ru_list_data()
peer_results = []
peer_results, hard_fail = [], []
for ru in rus:
cmd = f"""
python3 - <<'PY'
import pathlib, re
p = pathlib.Path('/etc/wireguard/wg_ru.conf')
t = p.read_text()
parts = re.split(r'(\\[Peer\\])', t)
result = parts[0]
i = 1
while i < len(parts):
block = parts[i] + (parts[i+1] if i+1 < len(parts) else '')
if {target['pubkey']!r} in block:
i += 2; continue
result += block
i += 2
p.write_text(result)
PY
wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1
"""
cmd = (
f"set -e; cd /etc/wireguard; "
f"cp -a wg_ru.conf wg_ru.conf.bak.$(date +%s); "
f"python3 -c \"import pathlib,re,sys; "
f"p=pathlib.Path('wg_ru.conf'); t=p.read_text(); "
f"blocks=re.split(r'(?m)(?=^\\[Peer\\])', t); "
f"kept=[b for b in blocks if {pubkey!r} not in b]; "
f"p.write_text(''.join(kept))\"; "
f"wg set wg_ru peer {shlex.quote(pubkey)} remove 2>/dev/null || true; "
f"echo OK"
)
out_pr, rc_pr = ssh_ru(ru, cmd, timeout=20)
peer_results.append({"ru": ru["id"], "ok": rc_pr == 0})
ok = (rc_pr == 0 and "OK" in (out_pr or ""))
peer_results.append({"ru": ru["id"], "ok": ok, "msg": (out_pr or "")[-200:]})
if not ok:
hard_fail.append(f"{ru['id']}: rc={rc_pr} {(out_pr or '')[-120:]}")
if hard_fail and not force:
return jsonify(
error=("не удалось убрать peer на: " + "; ".join(hard_fail) +
". Повторите с ?force=1 чтобы удалить запись принудительно."),
peers=peer_results,
), 502
data["ams_servers"] = [a for a in data["ams_servers"] if a["id"] != target["id"]]
save_and_distribute(data)
return jsonify(removed=target["id"], peers=peer_results)
ok, err = save_and_distribute(data)
if not ok and not force:
return jsonify(error=f"sync конфига: {err}", peers=peer_results), 500
return jsonify(removed=target["id"], peers=peer_results, force=force)
@app.route("/api/domains", methods=["POST"])

View File

@@ -51,6 +51,17 @@ button:hover:not(:disabled) { background: #30363d; border-color: #58a6ff; color:
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { color: #f85149; border-color: #4a1f23; }
button.danger:hover:not(:disabled) { background: #4a1f23; color: #fff; border-color: #f85149; }
button.danger.solid {
background: #b1361a;
color: #fff;
border-color: #f85149;
font-weight: 600;
}
button.danger.solid:hover:not(:disabled) {
background: #f85149;
color: #fff;
border-color: #f85149;
}
button.sm { padding: 2px 8px; font-size: 11px; }
.actions {

View File

@@ -79,18 +79,25 @@
<!-- Ам. серверы -->
<section>
<h2>Ам. серверы</h2>
<h2>Ам. серверы (зарубежные)</h2>
<table>
<thead><tr><th>ID</th><th>Host</th><th>SSH</th><th>Tunnel</th><th>Pubkey</th><th></th></tr></thead>
<tbody>
<template x-for="a in state.ams_servers" :key="a.id">
<tr>
<td><strong x-text="a.id"></strong> <span class="muted" x-show="a.is_local">(local)</span></td>
<td><strong x-text="a.id"></strong> <span class="muted" x-show="a.is_local">(этот сервер)</span></td>
<td x-text="a.host"></td>
<td x-text="a.ssh_port"></td>
<td x-text="a.tunnel_ip"></td>
<td class="muted mono" x-text="a.pubkey.slice(0, 20) + '...'"></td>
<td><button class="danger" @click="removeAms(a.id)" :disabled="a.is_local">удалить</button></td>
<td>
<template x-if="!a.is_local">
<button class="danger solid" @click="removeAms(a.id)">удалить</button>
</template>
<template x-if="a.is_local">
<span class="muted" title="нельзя удалить сервер, на котором запущен WebUI/бот">⛔ нельзя</span>
</template>
</td>
</tr>
</template>
</tbody>
@@ -258,8 +265,18 @@
this.load();
},
async removeAms(id) {
if (!confirm(`Удалить ам. '${id}'? Snimet peer на всех RU.`)) return;
await this.req('DELETE',`/api/ams/${id}`); this.flash('удалено'); this.load();
if (!confirm(`Удалить зарубежный сервер '${id}'?\n\nЧто произойдёт:\n • peer уберётся со всех RU-серверов\n • запись удалится из конфига\n • сам сервер ${id} останется жив, но больше не будет частью каскада\n\nПродолжить?`)) return;
try {
const r = await this.req('DELETE',`/api/ams/${id}`);
const peers = (r.peers||[]).map(p => `${p.ru}: ${p.ok?'✓':'✗'}`).join(', ');
this.flash(`удалён ${id}${peers ? ' ('+peers+')' : ''}`);
} catch (e) {
if (confirm(`Ошибка: ${e.message}\n\nУдалить запись принудительно (force, даже если RU-серверы недоступны)?`)) {
await this.req('DELETE',`/api/ams/${id}?force=1`);
this.flash(`удалён ${id} (force)`);
}
}
this.load();
},
async addDomains() {
const doms = this.forms.domains.split(/[\s,]+/).map(s=>s.trim()).filter(Boolean);