mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +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();
|
||||
|
||||
@@ -183,6 +183,35 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold" id="instances-panel" open>
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
<span class="fold-titles">
|
||||
<span class="fold-h">Протоколы / инстансы</span>
|
||||
<span class="muted fold-meta">Разверните AmneziaWG прямо из панели — без приложения Amnezia, с выбором порта</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="panel-fold-body">
|
||||
<div id="instances-grid" class="instances-grid"></div>
|
||||
<form id="instance-form" class="instance-form">
|
||||
<div class="instance-form-row">
|
||||
<label for="instance-variant">Протокол</label>
|
||||
<select id="instance-variant" class="form-select">
|
||||
<option value="awg2">AmneziaWG 2.0 (рекомендуется)</option>
|
||||
<option value="awg">AmneziaWG</option>
|
||||
<option value="legacy">AmneziaWG Legacy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="instance-form-row">
|
||||
<label for="instance-port">Порт (UDP)</label>
|
||||
<input id="instance-port" type="number" min="1" max="65535" placeholder="напр. 51820" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn primary">+ Развернуть инстанс</button>
|
||||
</form>
|
||||
<p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold" id="direct-panel" open>
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
|
||||
@@ -1178,3 +1178,26 @@ tr:last-child td {
|
||||
line-height: 1;
|
||||
}
|
||||
.actions a.btn.icon { text-decoration: none; }
|
||||
|
||||
|
||||
/* Managed instances */
|
||||
.instances-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-bottom: 16px; }
|
||||
.inst-card { background: var(--card, rgba(148,163,184,0.06)); border: 1px solid var(--line); border-radius: 16px; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.inst-card-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.inst-ico { width: 44px; height: 44px; border-radius: 12px; background: rgba(124,92,255,0.14); display: flex; align-items: center; justify-content: center; font-size: 22px; }
|
||||
.inst-title { font-weight: 700; font-size: 1.05rem; }
|
||||
.inst-badge { font-size: 0.62rem; background: #7c5cff; color: #fff; border-radius: 999px; padding: 2px 7px; vertical-align: middle; letter-spacing: 0.05em; }
|
||||
.inst-desc { font-size: 0.82rem; line-height: 1.35; min-height: 2.4em; }
|
||||
.inst-status.on { color: #34c759; font-size: 0.72rem; font-weight: 700; }
|
||||
.inst-status.off { color: #ff9f0a; font-size: 0.72rem; font-weight: 700; }
|
||||
.inst-status-row { margin: 2px 0; }
|
||||
.inst-stats { display: flex; gap: 18px; margin: 4px 0; }
|
||||
.inst-stat-l { display: block; font-size: 0.62rem; color: var(--muted); letter-spacing: 0.06em; }
|
||||
.inst-stat-v { display: block; font-weight: 700; font-size: 0.95rem; }
|
||||
.inst-actions { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.inst-actions .btn { flex: 1; }
|
||||
.instance-form { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-top: 8px; }
|
||||
.instance-form-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.instance-form-row label { font-size: 0.75rem; color: var(--muted); }
|
||||
.instance-hint { font-size: 0.75rem; margin-top: 8px; }
|
||||
.instance-empty { padding: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user