feat: Cloudflare WARP routing for AmneziaWG

Web toggles per IPv4 peer; wgcf install script on host (no Telegram/QR).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 18:00:39 +03:00
parent 448fe3b360
commit 6f6fb5f9b9
7 changed files with 922 additions and 25 deletions

View File

@@ -15,6 +15,12 @@ const statusEl = document.querySelector("#status");
const peerCountEl = document.querySelector("#peer-count");
const wgShowEl = document.querySelector("#wg-show");
const warpPanel = document.querySelector("#warp-panel");
const warpStatusLine = document.querySelector("#warp-status-line");
const warpActionsEl = document.querySelector("#warp-actions");
const warpClientListEl = document.querySelector("#warp-client-list");
const warpWgShowEl = document.querySelector("#warp-wg-show");
const protoSwitch = document.querySelector("#proto-switch");
const protoSelect = document.querySelector("#proto-select");
const protoLabel = document.querySelector("#proto-label");
@@ -514,7 +520,11 @@ function renderRows(clients) {
const stTd = document.createElement("td");
const badge = document.createElement("span");
badge.className = `badge ${c.activeInConf ? "on" : "off"}`;
badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
if (c.activeInConf && c.warpEnabled) {
badge.textContent = "В туннеле · WARP";
} else {
badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
}
stTd.appendChild(badge);
const offTd = document.createElement("td");
@@ -554,6 +564,137 @@ function btn(label, cls, onClick) {
return b;
}
/** Для политики WARP на сервере нужны IPv4 вида 10.8.x.x/32 */
function parseIpv4Cidrs(allowedIps) {
if (!allowedIps) return [];
return String(allowedIps)
.split(",")
.map((x) => x.trim())
.filter((x) => /^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(x));
}
/** @param {{ warp?: Record<string, unknown>; clients: Record<string, unknown>[] }} data */
function renderWarpPanel(data) {
if (!warpPanel || !warpStatusLine || !warpActionsEl || !warpClientListEl || !warpWgShowEl) return;
const w = data.warp;
if (!w || w.supported === false) {
warpPanel.hidden = true;
return;
}
warpPanel.hidden = false;
warpActionsEl.innerHTML = "";
warpClientListEl.innerHTML = "";
warpWgShowEl.textContent = typeof w.wgShowWarp === "string" ? w.wgShowWarp : "";
if (!w.installed) {
warpStatusLine.textContent = "Не установлен";
const hint = document.createElement("p");
hint.className = "muted warp-muted";
hint.innerHTML =
"Один раз на хосте (root): <code class=\"inline\">bash scripts/warp-amnezia.sh install</code> — из каталога клона репозитория на VPS. Если контейнер не угадан автоматически: <code class=\"inline\">bash scripts/warp-amnezia.sh install amnezia-awg2</code>.";
warpActionsEl.appendChild(hint);
return;
}
const parts = [];
parts.push(w.running ? "Интерфейс warp поднят" : "Интерфейс warp опущен");
if (w.exitIp) parts.push(`выход ${w.exitIp}`);
warpStatusLine.textContent = parts.join(" · ");
const selection = new Set((w.selectedAllowedIps || []).map(String));
function redrawChecks() {
warpClientListEl.innerHTML = "";
const frag = document.createDocumentFragment();
let any = false;
for (const c of data.clients) {
if (!c.activeInConf) continue;
const ips = parseIpv4Cidrs(c.allowedIps);
if (!ips.length) continue;
any = true;
const ip = ips[0];
const label = document.createElement("label");
label.className = "warp-check-row";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = selection.has(ip);
cb.addEventListener("change", () => {
if (cb.checked) selection.add(ip);
else selection.delete(ip);
});
const span = document.createElement("span");
span.textContent = `${c.name} · ${ip}`;
label.append(cb, span);
frag.appendChild(label);
}
warpClientListEl.appendChild(frag);
if (!any) {
const p = document.createElement("p");
p.className = "muted warp-muted";
p.textContent =
"Нет активных клиентов с IPv4 AllowedIPs (/32) — WARP-политика в вебе работает только для таких адресов.";
warpClientListEl.appendChild(p);
}
}
redrawChecks();
warpActionsEl.appendChild(
btn("Поднять WARP", "btn small primary", async () => {
try {
setStatus("Поднимаю WARP…", false);
await api("/api/warp/start", { method: "POST", body: JSON.stringify({}) });
setStatus("Готово.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}),
);
warpActionsEl.appendChild(
btn("Остановить WARP", "btn small ghost", async () => {
try {
setStatus("Останавливаю WARP…", false);
await api("/api/warp/stop", { method: "POST", body: JSON.stringify({}) });
setStatus("Готово.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}),
);
warpActionsEl.appendChild(
btn("Все в WARP", "btn small ghost", () => {
for (const c of data.clients) {
if (!c.activeInConf) continue;
parseIpv4Cidrs(c.allowedIps).forEach((ip) => selection.add(ip));
}
redrawChecks();
}),
);
warpActionsEl.appendChild(
btn("Никого", "btn small ghost", () => {
selection.clear();
redrawChecks();
}),
);
warpActionsEl.appendChild(
btn("Применить маршрутизацию", "btn small primary", async () => {
try {
setStatus("Сохраняю WARP и перезапускаю контейнер AWG…", false);
await api("/api/warp/routing", {
method: "POST",
body: JSON.stringify({ selectedAllowedIps: [...selection] }),
});
setStatus("Готово.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}),
);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
@@ -609,6 +750,7 @@ async function loadClients() {
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
wgShowEl.textContent = data.wgShow || "";
renderWarpPanel(data);
renderRows(data.clients);
setStatus("", false);
void refreshServerClock();
@@ -624,6 +766,7 @@ async function loadClients() {
rowsEl.innerHTML = "";
wgShowEl.textContent = "";
peerCountEl.textContent = "";
if (warpPanel) warpPanel.hidden = true;
}
}

View File

@@ -88,6 +88,24 @@
<p id="status" class="status" role="status"></p>
<section class="panel warp-panel" id="warp-panel" hidden>
<div class="panel-head">
<h2>Cloudflare WARP</h2>
<span class="muted" id="warp-status-line"></span>
</div>
<p class="muted warp-intro">
Выход в интернет через Cloudflare WARP для выбранных клиентов AmneziaWG (правила в контейнере: таблица маршрутизации и NAT).
Установка без Telegram и QR — один раз на хосте: <code class="inline">bash scripts/warp-amnezia.sh install</code>
(из каталога репозитория, под root). Дальше — галочки ниже и «Применить» (контейнер перезапустится).
</p>
<div id="warp-actions" class="warp-actions"></div>
<div id="warp-client-list" class="warp-client-list"></div>
<details class="raw warp-raw">
<summary>Вывод wg show warp</summary>
<pre id="warp-wg-show"></pre>
</details>
</section>
<section class="panel">
<div class="panel-head">
<h2>Пользователи</h2>

View File

@@ -663,3 +663,59 @@ tr:last-child td {
color: var(--muted);
max-width: 62rem;
}
/* --- Cloudflare WARP --- */
.warp-panel .warp-intro {
font-size: 0.88rem;
line-height: 1.55;
margin: 0 0 1rem;
}
.warp-panel .warp-intro code.inline {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.78rem;
padding: 0.12rem 0.38rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--line);
}
.warp-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.warp-client-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.85rem;
}
.warp-check-row {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.88rem;
padding: 0.35rem 0.5rem;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.2);
}
.warp-check-row input {
flex-shrink: 0;
}
.warp-muted {
font-size: 0.78rem;
color: var(--muted);
}
.warp-raw pre {
max-height: 220px;
overflow: auto;
}