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({