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:
@@ -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
|
||||
|
||||
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; }
|
||||
|
||||
167
scripts/awg-instance.sh
Executable file
167
scripts/awg-instance.sh
Executable 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
|
||||
@@ -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
188
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({
|
||||
|
||||
Reference in New Issue
Block a user