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:
andrey271192
2026-06-16 21:33:02 +03:00
parent f1b7fa522c
commit 546c1127f8
7 changed files with 505 additions and 13 deletions

View File

@@ -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

View File

@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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("Укажите корректный порт (165535).", 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();

View File

@@ -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>

View File

@@ -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; }

167
scripts/awg-instance.sh Executable file
View File

@@ -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 <variant> <port> [name]
# awg-instance.sh remove <name>
# 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 </dev/urandom | tr -d ' '; }
rand_range() { # min max
local min="$1"
local max="$2"
local span=$(( max - min + 1 ))
echo $(( min + ($(rand) % span) ))
}
rand_magic() { # single large uint32 (AmneziaWG H header), NOT a range
rand_range 1000000000 2147000000
}
image_for() {
case "$1" in
awg2) echo "$IMG_AWG2" ;;
awg) echo "$IMG_AWG" ;;
legacy) echo "$IMG_LEGACY" ;;
*) err "unknown variant: $1 (awg2|awg|legacy)" ;;
esac
}
# pick an unused 10.8.<N>.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" <<EOF
#!/bin/sh
set +e
$usimpl
${binary}-quick down /opt/amnezia/awg/${iface}.conf 2>/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 <variant> <port> [name] | remove <name> | list}" ;;
esac

View File

@@ -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

188
server.js
View File

@@ -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: "Некорректный порт (165535)." });
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({