diff --git a/Dockerfile b/Dockerfile index bc3e484..2738da5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine -RUN apk add --no-cache docker-cli openssh-client sshpass +RUN apk add --no-cache docker-cli openssh-client sshpass bash iproute2 coreutils RUN mkdir -p /data && chmod 700 /data @@ -10,6 +10,7 @@ COPY package.json ./ RUN npm install --omit=dev COPY server.js ./server.js +COPY scripts ./scripts COPY public ./public ENV NODE_ENV=production diff --git a/public/app.js b/public/app.js index 54f05ef..7b3915b 100644 --- a/public/app.js +++ b/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 = `
Не удалось загрузить инстансы: ${escapeHtmlSafe(String(e.message || e))}
`; + return; + } + const list = data.instances || []; + if (!list.length) { + grid.innerHTML = `
Нет развёрнутых инстансов. Создайте первый формой ниже.
`; + return; + } + grid.innerHTML = list.map((i) => { + const m = VARIANT_META[i.variant] || { icon: "🛡", title: i.variant, badge: "" }; + const status = i.running + ? `● РАБОТАЕТ` + : `● ОСТАНОВЛЕН`; + const toggle = i.running + ? `` + : ``; + const badge = m.badge ? `${m.badge}` : ""; + return `
+
+
${m.icon}
+ ${toggle} +
+
${escapeHtmlSafe(m.title)} ${badge}
+
${escapeHtmlSafe(i.variantMeta?.desc || "")}
+
${status}
+
+
ПОРТ${i.port || "?"}/UDP
+
ПОДКЛЮЧЕНИЯ${i.peers == null ? "—" : i.peers}
+
+
+ + +
+
`; + }).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(); diff --git a/public/index.html b/public/index.html index 1dfe2a9..d0900cb 100644 --- a/public/index.html +++ b/public/index.html @@ -183,6 +183,35 @@ +
+ + + + Протоколы / инстансы + Разверните AmneziaWG прямо из панели — без приложения Amnezia, с выбором порта + + +
+
+
+
+ + +
+
+ + +
+ +
+

Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов.

+
+
+
diff --git a/public/styles.css b/public/styles.css index 605e243..e71e883 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } diff --git a/scripts/awg-instance.sh b/scripts/awg-instance.sh new file mode 100755 index 0000000..39e6105 --- /dev/null +++ b/scripts/awg-instance.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Deploy / remove an AmneziaWG server instance from public images — no Amnezia app. +# Variants: awg2 (AmneziaWG 2.0, awg-go 2.0.0), awg (classic, awg-go 0.2.18), +# legacy (kernel WireGuard, amnezia-wg). +# Usage: +# awg-instance.sh create [name] +# awg-instance.sh remove +# awg-instance.sh list +set -euo pipefail + +INSTANCES_DIR="${INSTANCES_DIR:-/opt/amnezia-instances}" +IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}" +IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}" +IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}" + +err() { echo "ERROR: $*" >&2; exit 1; } + +rand() { od -An -N4 -tu4 .0/24 subnet +pick_subnet() { + local used n c + used="$(grep -rhoE 'Address = 10\.8\.[0-9]+\.' "$INSTANCES_DIR"/*/conf/*.conf 2>/dev/null | grep -oE '10\.8\.[0-9]+' | awk -F. '{print $3}' | sort -un || true)" + # also subnets used by any running container (native Amnezia, etc.) + for c in $(docker ps --format '{{.Names}}' 2>/dev/null); do + local a + a="$(docker exec "$c" sh -c 'cat /opt/amnezia/awg/*.conf 2>/dev/null' 2>/dev/null | grep -oE 'Address = 10\.8\.[0-9]+' | grep -oE '10\.8\.[0-9]+' | awk -F. '{print $3}' || true)" + [[ -n "$a" ]] && used="$used"$'\n'"$a" + done + used="$(printf '%s\n' "$used" | sort -un)" + # start at 20 to avoid colliding with Amnezia native default 10.8.1 + for n in $(seq 20 250); do + if ! grep -qx "$n" <<<"$used"; then echo "$n"; return; fi + done + err "no free subnet" +} + +cmd_create() { + local variant="$1" port="$2" name="${3:-}" + [[ "$port" =~ ^[0-9]+$ ]] && (( port>=1 && port<=65535 )) || err "bad port: $port" + local img; img="$(image_for "$variant")" + [[ -z "$name" ]] && name="amnezia-${variant}-${port}" + # sanitize name + name="$(echo "$name" | tr -cd 'a-zA-Z0-9_-')" + [[ -n "$name" ]] || err "bad name" + docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists" + # port free? + if ss -lun 2>/dev/null | grep -qE "[:.]${port}\b"; then err "udp port $port busy"; fi + + echo "→ pulling $img" + docker pull -q "$img" >/dev/null + + local sub; sub="$(pick_subnet)" + local net="10.8.${sub}" + local dir="$INSTANCES_DIR/$name/conf" + mkdir -p "$dir" + + local conf binary iface + if [[ "$variant" == "legacy" ]]; then binary="wg"; iface="wg0"; else binary="awg"; iface="awg0"; fi + conf="$dir/${iface}.conf" + + # keys via the image + local priv pub psk + priv="$(docker run --rm "$img" "$binary" genkey | tr -d '\r\n')" + pub="$(printf '%s' "$priv" | docker run --rm -i "$img" "$binary" pubkey | tr -d '\r\n')" + psk="$(docker run --rm "$img" "$binary" genpsk | tr -d '\r\n')" + + # interface block + { + echo "[Interface]" + echo "PrivateKey = $priv" + echo "Address = ${net}.0/24" + echo "ListenPort = $port" + if [[ "$variant" != "legacy" ]]; then + echo "Jc = $(rand_range 3 10)" + echo "Jmin = 10" + echo "Jmax = 50" + echo "S1 = $(rand_range 15 60)" + echo "S2 = $(rand_range 15 60)" + if [[ "$variant" == "awg2" ]]; then + echo "S3 = $(rand_range 5 40)" + echo "S4 = $(rand_range 1 40)" + fi + echo "H1 = $(rand_magic)" + echo "H2 = $(rand_magic)" + echo "H3 = $(rand_magic)" + echo "H4 = $(rand_magic)" + fi + } > "$conf" + + printf '%s\n' "$pub" > "$dir/wireguard_server_public_key.key" + printf '%s\n' "$priv" > "$dir/wireguard_server_private_key.key" + printf '%s\n' "$psk" > "$dir/wireguard_psk.key" + printf '[]\n' > "$dir/clientsTable" + + # start script run inside container + local usimpl="" + if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi + cat > "$dir/start.sh" </dev/null +[ -f /opt/amnezia/awg/${iface}.conf ] && ${binary}-quick up /opt/amnezia/awg/${iface}.conf +DEV=\$(ip route 2>/dev/null | awk '/default/ {print \$5; exit}') +[ -z "\$DEV" ] && DEV=eth0 +iptables -A INPUT -i ${iface} -j ACCEPT +iptables -A FORWARD -i ${iface} -j ACCEPT +iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -t nat -A POSTROUTING -s ${net}.0/24 -o "\$DEV" -j MASQUERADE +exec tail -f /dev/null +EOF + chmod +x "$dir/start.sh" + + echo "→ run container $name (port $port/udp, subnet ${net}.0/24)" + docker run -d --name "$name" --restart unless-stopped \ + --cap-add NET_ADMIN --cap-add SYS_MODULE --privileged \ + --sysctl net.ipv4.conf.all.src_valid_mark=1 \ + -p "${port}:${port}/udp" \ + -v /lib/modules:/lib/modules:ro \ + -v "$dir:/opt/amnezia/awg" \ + "$img" sh /opt/amnezia/awg/start.sh >/dev/null + + sleep 2 + if docker exec "$name" "$binary" show "$iface" >/dev/null 2>&1; then + echo "OK container=$name variant=$variant port=$port subnet=${net}.0/24 binary=$binary iface=$iface conf=$conf" + else + echo "WARN container started but '$binary show $iface' failed — check: docker logs $name" + echo "OK_PARTIAL container=$name variant=$variant port=$port" + fi +} + +cmd_remove() { + local name="$1" + docker rm -f "$name" >/dev/null 2>&1 || true + rm -rf "${INSTANCES_DIR:?}/$name" + echo "removed $name" +} + +cmd_list() { + docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-(awg2|awg|legacy)-|^amnezia-' || true +} + +case "${1:-}" in + create) shift; cmd_create "$@" ;; + remove) shift; cmd_remove "$@" ;; + list) cmd_list ;; + *) err "usage: $0 {create [name] | remove | list}" ;; +esac diff --git a/scripts/install.sh b/scripts/install.sh index c19cd85..2ee4312 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -80,6 +80,7 @@ if [[ "${SKIP_DOWNLOAD:-}" != "1" ]]; then fi mkdir -p "${DATA_DIR}" +mkdir -p /opt/amnezia-instances # При повторном запуске не менять внешний порт панели, если не указали HOST_PORT явно (по умолчанию 8080). PREV_HOST_PORT="" @@ -276,6 +277,7 @@ docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \ -p "${HOST_PORT}:3980" \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "${DATA_DIR}:/data" \ + -v /opt/amnezia-instances:/opt/amnezia-instances \ "${RUN_ENV[@]}" \ amnezia-admin:latest diff --git a/server.js b/server.js index b80ad28..140ec5a 100644 --- a/server.js +++ b/server.js @@ -135,8 +135,73 @@ function parseProfilesFromEnv() { } } -const PROFILES = parseProfilesFromEnv(); -if (!PROFILES.length) { +const ENV_PROFILES = parseProfilesFromEnv(); + +const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances"; +const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`; +const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`; + +const INSTANCE_VARIANTS = { + awg2: { + label: "AmneziaWG 2.0", + desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).", + iface: "awg0", binary: "awg", + }, + awg: { + label: "AmneziaWG", + desc: "Версия протокола на основе awg-go. Обфускация S1, S2.", + iface: "awg0", binary: "awg", + }, + legacy: { + label: "AmneziaWG Legacy", + desc: "Оригинальная версия на ядре WireGuard. Совместима с клиентами старых версий.", + iface: "wg0", binary: "wg", + }, +}; + +function loadManagedProfiles() { + try { + const raw = fs.readFileSync(INSTANCES_FILE, "utf-8"); + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return []; + return arr.map((m) => ({ + id: String(m.id), + label: String(m.label || m.id), + container: String(m.container || m.id), + confPath: String(m.confPath || `/opt/amnezia/awg/${m.iface || "awg0"}.conf`), + clientsPath: String(m.clientsPath || "/opt/amnezia/awg/clientsTable"), + iface: String(m.iface || "awg0"), + wgBinary: String(m.wgBinary || "awg"), + pskPath: String(m.pskPath || "/opt/amnezia/awg/wireguard_psk.key"), + warpDir: "/opt/warp", + warpConf: "/opt/warp/warp.conf", + warpClientsList: "/opt/warp/clients.list", + startScript: "/opt/amnezia/awg/start.sh", + managed: true, + variant: String(m.variant || "awg2"), + port: Number(m.port) || null, + })); + } catch { + return []; + } +} + +function saveManagedProfiles(list) { + fs.mkdirSync(path.dirname(INSTANCES_FILE), { recursive: true }); + fs.writeFileSync(INSTANCES_FILE, JSON.stringify(list, null, 2)); +} + +// Effective profile set = env profiles + managed instances (deduped by id). +function getProfiles() { + const managed = loadManagedProfiles(); + const seen = new Set(ENV_PROFILES.map((p) => p.id)); + const out = [...ENV_PROFILES]; + for (const m of managed) if (!seen.has(m.id)) { out.push(m); seen.add(m.id); } + return out; +} + +const PROFILES = ENV_PROFILES; // boot-time check below uses env only +if (!ENV_PROFILES.length) { console.error("Нет ни одного профиля AWG: укажите container в AWG_PROFILES или переменные по умолчанию."); process.exit(1); } @@ -363,7 +428,7 @@ function runtimeFromExportRequest(req) { req.method === "POST" && typeof req.body?.profileId === "string" ? req.body.profileId.trim() : ""; const pid = qPid || bodyPid; if (pid) { - const p = PROFILES.find((x) => x.id === pid); + const p = getProfiles().find((x) => x.id === pid); if (p) return createRuntime(p); } return runtimeForRequest(req); @@ -826,7 +891,8 @@ function createRuntime(profile) { function runtimeForRequest(req) { const wanted = getProfileCookie(req); - const profile = PROFILES.find((p) => p.id === wanted) || PROFILES[0]; + const all = getProfiles(); + const profile = all.find((p) => p.id === wanted) || all[0]; return createRuntime(profile); } @@ -1271,7 +1337,7 @@ async function processScheduledDisconnects(rt) { } async function processAllScheduledDisconnects() { - for (const profile of PROFILES) { + for (const profile of getProfiles()) { await processScheduledDisconnects(createRuntime(profile)); } } @@ -1918,7 +1984,7 @@ app.post("/api/change-password", requireAuth, (req, res) => { app.get("/api/protocols", requireAuth, (req, res) => { const rt = runtimeForRequest(req); const hintSingle = - PROFILES.length < 2 + getProfiles().length < 2 ? IS_COMMUNITY ? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO." : "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json." @@ -1926,12 +1992,12 @@ app.get("/api/protocols", requireAuth, (req, res) => { res.json({ currentId: rt.profile.id, currentLabel: rt.profile.label, - profiles: PROFILES.map((p) => ({ + profiles: getProfiles().map((p) => ({ id: p.id, label: p.label, container: p.container, })), - singleProfile: PROFILES.length < 2, + singleProfile: getProfiles().length < 2, profilesPersistHint: hintSingle, edition: editionPayload(), }); @@ -1939,7 +2005,7 @@ app.get("/api/protocols", requireAuth, (req, res) => { app.post("/api/protocol", requireAuth, (req, res) => { const id = req.body?.profileId; - if (typeof id !== "string" || !PROFILES.some((p) => p.id === id)) { + if (typeof id !== "string" || !getProfiles().some((p) => p.id === id)) { res.status(400).json({ error: "Неизвестный profileId" }); return; } @@ -2034,9 +2100,9 @@ async function serveClientConfigExport(req, res) { let rt; if (tokenOk) { - if (PROFILES.length > 1) { + if (getProfiles().length > 1) { const pid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; - const p = PROFILES.find((x) => x.id === pid); + const p = getProfiles().find((x) => x.id === pid); if (!p) { res.status(400).json({ error: @@ -2046,7 +2112,7 @@ async function serveClientConfigExport(req, res) { } rt = createRuntime(p); } else { - rt = createRuntime(PROFILES[0]); + rt = createRuntime(getProfiles()[0]); } } else { rt = runtimeFromExportRequest(req); @@ -2570,6 +2636,104 @@ if (fs.existsSync(pub)) { ); } +// ───────────────────────── Managed AmneziaWG instances ───────────────────────── +function runInstanceScript(args) { + return new Promise((resolve, reject) => { + const child = spawn("bash", [INSTANCE_SCRIPT, ...args], { + env: { ...process.env, INSTANCES_DIR }, + }); + let out = "", err = ""; + child.stdout.on("data", (c) => (out += c)); + child.stderr.on("data", (c) => (err += c)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(out); + else reject(new Error((err || out || `exit ${code}`).trim())); + }); + }); +} + +app.get("/api/instances", requireAuth, async (_req, res) => { + const managed = loadManagedProfiles(); + const running = await listRunningContainerNames(); + const items = []; + for (const m of managed) { + let peers = null; + if (running.includes(m.container)) { + try { + const out = (await execDocker(["exec", m.container, m.wgBinary, "show", m.iface, "peers"])).stdout || ""; + peers = out.split("\n").map((x) => x.trim()).filter(Boolean).length; + } catch { peers = null; } + } + items.push({ + id: m.id, label: m.label, variant: m.variant, port: m.port, + container: m.container, + running: running.includes(m.container), + peers, + variantMeta: INSTANCE_VARIANTS[m.variant] || null, + }); + } + res.json({ instances: items, variants: INSTANCE_VARIANTS }); +}); + +app.post("/api/instances/create", requireAuth, requireProTier, async (req, res) => { + const variant = String(req.body?.variant || "").trim(); + const port = Number(req.body?.port); + if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." }); + if (!Number.isInteger(port) || port < 1 || port > 65535) return res.status(400).json({ error: "Некорректный порт (1–65535)." }); + const name = `amnezia-${variant}-${port}`; + try { + const out = await runInstanceScript(["create", variant, String(port), name]); + const meta = INSTANCE_VARIANTS[variant]; + const list = loadManagedProfiles(); + if (!list.some((p) => p.id === name)) { + list.push({ + id: name, + label: `${meta.label} :${port}`, + container: name, + confPath: `/opt/amnezia/awg/${meta.iface}.conf`, + clientsPath: "/opt/amnezia/awg/clientsTable", + iface: meta.iface, + wgBinary: meta.binary, + pskPath: "/opt/amnezia/awg/wireguard_psk.key", + variant, port, + }); + saveManagedProfiles(list); + } + res.json({ ok: true, id: name, output: out.slice(0, 4000) }); + } catch (e) { + res.status(500).json({ error: String(e.message || e).slice(0, 1500) }); + } +}); + +app.post("/api/instances/delete", requireAuth, requireProTier, async (req, res) => { + const id = String(req.body?.id || "").trim(); + const list = loadManagedProfiles(); + const found = list.find((p) => p.id === id); + if (!found) return res.status(404).json({ error: "Инстанс не найден." }); + try { + await runInstanceScript(["remove", id]); + } catch (e) { + console.warn("instance remove:", e); + } + saveManagedProfiles(list.filter((p) => p.id !== id)); + res.json({ ok: true }); +}); + +app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) => { + const id = String(req.body?.id || "").trim(); + if (!loadManagedProfiles().some((p) => p.id === id)) return res.status(404).json({ error: "Инстанс не найден." }); + try { await execDocker(["stop", id]); res.json({ ok: true }); } + catch (e) { res.status(500).json({ error: String(e.message || e) }); } +}); + +app.post("/api/instances/start", requireAuth, requireProTier, async (req, res) => { + const id = String(req.body?.id || "").trim(); + if (!loadManagedProfiles().some((p) => p.id === id)) return res.status(404).json({ error: "Инстанс не найден." }); + try { await execDocker(["start", id]); res.json({ ok: true }); } + catch (e) { res.status(500).json({ error: String(e.message || e) }); } +}); + app.use((req, res) => { if (typeof req.path === "string" && req.path.startsWith("/api/")) { res.status(404).json({