mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-21 14:51:59 +00:00
feat: deploy AmneziaWG instances from the panel (no Amnezia app)
Add a "Протоколы / инстансы" section that spins up AmneziaWG server containers straight from the panel, with port + variant selection — no need to run the Amnezia desktop app to set up the server. Core (scripts/awg-instance.sh): - create <awg2|awg|legacy> <port> [name]: pulls the public image (amneziavpn/amneziawg-go:2.0.0 / :0.2.18 / amneziavpn/amnezia-wg), generates server keys + psk, writes awg0.conf/wg0.conf with a free 10.8.<N>.0/24 subnet (scans running containers to avoid clashes), random AmneziaWG obfuscation (Jc/Jmin/Jmax/S1..S4/H1..H4 as single uint32 values — ranges break awg setconf), and a start.sh that brings the iface up via userspace amneziawg-go + NAT MASQUERADE. remove <name>, list. Backend (server.js): - Profiles are now dynamic: env AWG_PROFILES merged with managed instances persisted in /data/instances.json (getProfiles()), so a new instance is usable immediately without restarting the panel. - /api/instances (list with running/peers), /api/instances/create, /delete, /stop, /start. create runs the script then registers the profile; delete tears down container + data + profile. Infra: - Dockerfile: add bash iproute2 coreutils, COPY scripts. - install.sh: mkdir /opt/amnezia-instances and bind-mount it into the panel so docker-in-docker bind paths line up. UI (index.html/app.js/styles.css): - Cards per instance (icon, NEW badge, description, РАБОТАЕТ/ОСТАНОВЛЕН, port, connections) with Стоп/Старт, Подключения (switches the active instance), Удалить; plus a create form (variant + port). Verified end to end on a live VPS: create awg2/awg/legacy instances, interfaces come up, a client created on a new instance gets the right subnet (10.8.20.2) and Endpoint (host:51850).
This commit is contained in:
106
public/app.js
106
public/app.js
@@ -627,6 +627,7 @@ loginForm.addEventListener("submit", async (ev) => {
|
||||
await loadProtocols();
|
||||
await loadTimeSyncCaps();
|
||||
await loadClients();
|
||||
await loadInstances();
|
||||
} catch (e) {
|
||||
loginError.textContent = String(e.message || e);
|
||||
}
|
||||
@@ -644,6 +645,7 @@ logoutBtn.addEventListener("click", async () => {
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
loadClients();
|
||||
loadInstances();
|
||||
});
|
||||
|
||||
const cascadeForm = document.querySelector("#cascade-form");
|
||||
@@ -656,6 +658,11 @@ if (directForm) {
|
||||
directForm.addEventListener("submit", (ev) => void downloadDirectConf(ev));
|
||||
}
|
||||
|
||||
const instanceForm = document.querySelector("#instance-form");
|
||||
if (instanceForm) {
|
||||
instanceForm.addEventListener("submit", (ev) => void createInstance(ev));
|
||||
}
|
||||
|
||||
protoSelect.addEventListener("change", async () => {
|
||||
try {
|
||||
setStatus("Смена инстанса…", false);
|
||||
@@ -1417,6 +1424,104 @@ async function downloadDirectConf(ev) {
|
||||
}
|
||||
}
|
||||
|
||||
const VARIANT_META = {
|
||||
awg2: { icon: "✨", title: "AmneziaWG 2.0", badge: "NEW" },
|
||||
awg: { icon: "🔮", title: "AmneziaWG", badge: "" },
|
||||
legacy: { icon: "📡", title: "AmneziaWG Legacy", badge: "" },
|
||||
};
|
||||
|
||||
async function loadInstances() {
|
||||
const grid = document.querySelector("#instances-grid");
|
||||
if (!grid) return;
|
||||
let data;
|
||||
try {
|
||||
data = await api("/api/instances");
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="muted">Не удалось загрузить инстансы: ${escapeHtmlSafe(String(e.message || e))}</div>`;
|
||||
return;
|
||||
}
|
||||
const list = data.instances || [];
|
||||
if (!list.length) {
|
||||
grid.innerHTML = `<div class="muted instance-empty">Нет развёрнутых инстансов. Создайте первый формой ниже.</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list.map((i) => {
|
||||
const m = VARIANT_META[i.variant] || { icon: "🛡", title: i.variant, badge: "" };
|
||||
const status = i.running
|
||||
? `<span class="inst-status on">● РАБОТАЕТ</span>`
|
||||
: `<span class="inst-status off">● ОСТАНОВЛЕН</span>`;
|
||||
const toggle = i.running
|
||||
? `<button class="btn small ghost" data-act="stop" data-id="${i.id}">■ Стоп</button>`
|
||||
: `<button class="btn small primary" data-act="start" data-id="${i.id}">▶ Старт</button>`;
|
||||
const badge = m.badge ? `<span class="inst-badge">${m.badge}</span>` : "";
|
||||
return `<div class="inst-card">
|
||||
<div class="inst-card-head">
|
||||
<div class="inst-ico">${m.icon}</div>
|
||||
${toggle}
|
||||
</div>
|
||||
<div class="inst-title">${escapeHtmlSafe(m.title)} ${badge}</div>
|
||||
<div class="inst-desc muted">${escapeHtmlSafe(i.variantMeta?.desc || "")}</div>
|
||||
<div class="inst-status-row">${status}</div>
|
||||
<div class="inst-stats">
|
||||
<div><span class="inst-stat-l">ПОРТ</span><span class="inst-stat-v">${i.port || "?"}/UDP</span></div>
|
||||
<div><span class="inst-stat-l">ПОДКЛЮЧЕНИЯ</span><span class="inst-stat-v">${i.peers == null ? "—" : i.peers}</span></div>
|
||||
</div>
|
||||
<div class="inst-actions">
|
||||
<button class="btn small ghost" data-act="use" data-id="${i.id}">Подключения</button>
|
||||
<button class="btn small warn" data-act="delete" data-id="${i.id}" data-label="${escapeHtmlSafe(m.title)}">Удалить</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
grid.querySelectorAll("button[data-act]").forEach((b) => {
|
||||
b.addEventListener("click", () => void instanceAction(b.dataset.act, b.dataset.id, b.dataset.label));
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtmlSafe(x) {
|
||||
return String(x == null ? "" : x).replace(/[&<>"']/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[m]));
|
||||
}
|
||||
|
||||
async function instanceAction(act, id, label) {
|
||||
try {
|
||||
if (act === "stop") { await api("/api/instances/stop", { method: "POST", body: JSON.stringify({ id }) }); setStatus("Инстанс остановлен.", false); }
|
||||
else if (act === "start") { await api("/api/instances/start", { method: "POST", body: JSON.stringify({ id }) }); setStatus("Инстанс запущен.", false); }
|
||||
else if (act === "delete") {
|
||||
if (!confirm(`Удалить инстанс «${label || id}» (${id}) вместе со всеми клиентами на нём?`)) return;
|
||||
await api("/api/instances/delete", { method: "POST", body: JSON.stringify({ id }) });
|
||||
setStatus("Инстанс удалён.", false);
|
||||
} else if (act === "use") {
|
||||
const sel = document.querySelector("#proto-select");
|
||||
if (sel) { sel.value = id; sel.dispatchEvent(new Event("change")); }
|
||||
document.querySelector("#users-panel")?.scrollIntoView({ behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
await loadInstances();
|
||||
await loadClients();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function createInstance(ev) {
|
||||
ev.preventDefault();
|
||||
const variant = document.querySelector("#instance-variant")?.value;
|
||||
const port = Number(document.querySelector("#instance-port")?.value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) { setStatus("Укажите корректный порт (1–65535).", true); return; }
|
||||
const btn = ev.target.querySelector("button[type=submit]");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "⏳ Разворачиваю… (до минуты)"; }
|
||||
try {
|
||||
await api("/api/instances/create", { method: "POST", body: JSON.stringify({ variant, port }) });
|
||||
setStatus("Инстанс развёрнут.", false);
|
||||
const pe = document.querySelector("#instance-port"); if (pe) pe.value = "";
|
||||
await loadInstances();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "+ Развернуть инстанс"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function renameClient(c) {
|
||||
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
|
||||
if (next === null) return;
|
||||
@@ -1533,6 +1638,7 @@ async function boot() {
|
||||
await loadProtocols();
|
||||
await loadTimeSyncCaps();
|
||||
await loadClients();
|
||||
await loadInstances();
|
||||
} else {
|
||||
showLogin();
|
||||
loginPassword.focus();
|
||||
|
||||
Reference in New Issue
Block a user