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