feat: import clients from amnezia configs

This commit is contained in:
andrey271192
2026-06-21 18:22:36 +03:00
parent 8ba2a266e5
commit cff27e27b6
8 changed files with 305 additions and 5 deletions

View File

@@ -152,6 +152,20 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s
Если на хосте уже запущено **несколько** контейнеров с именами вида **`amnezia-awg*`**, а в панели по-прежнему один инстанс — перезапустите установщик из GitHub. Он автоматически создаст **`AWG_PROFILES`** и сохранит файл **`/root/amnezia-admin.awg-profiles.json`**. Ручной JSON нужен только при нестандартных путях внутри контейнеров. Если на хосте уже запущено **несколько** контейнеров с именами вида **`amnezia-awg*`**, а в панели по-прежнему один инстанс — перезапустите установщик из GitHub. Он автоматически создаст **`AWG_PROFILES`** и сохранит файл **`/root/amnezia-admin.awg-profiles.json`**. Ручной JSON нужен только при нестандартных путях внутри контейнеров.
### Импорт клиента из Amnezia backup / `.conf`
В панели есть блок **«Импорт клиента»**. Он нужен, когда клиент уже создан в приложении Amnezia, но в веб-панели его нет или у строки нет `last_config`.
Что поддерживается:
- вставка готового клиентского **`.conf`** с секциями `[Interface]` и `[Peer]`;
- вставка JSON backup, если внутри него есть строка с таким `.conf`;
- восстановление записи в **`clientsTable`** без дублирования peer, если peer уже есть в `awg0.conf`;
- добавление peer в текущий инстанс, если его ещё нет на сервере;
- сохранение исходного `.conf` в `userData.last_config`, чтобы дальше работали экспорт и управление из веба.
Перед импортом выберите нужный **«Инстанс»** вверху панели. Если backup приложения зашифрован или не содержит текстовый `.conf`, сначала экспортируйте клиентский конфиг из приложения Amnezia и вставьте его в форму импорта.
### Telegram MTProtoпрокси ### Telegram MTProtoпрокси
**Не связано с AmneziaWG.** Отдельный контейнер образа **[telegrammessenger/proxy](https://hub.docker.com/r/telegrammessenger/proxy)** на том же хосте. После входа в панель: установка, перезапуск, удаление, выбор порта на хосте, просмотр логов и ссылки **`tg://proxy`** (хост из **`MTPRO_PUBLIC_HOST`**, **`CLIENT_CONFIG_ENDPOINT`** или из URL страницы панели). **Не связано с AmneziaWG.** Отдельный контейнер образа **[telegrammessenger/proxy](https://hub.docker.com/r/telegrammessenger/proxy)** на том же хосте. После входа в панель: установка, перезапуск, удаление, выбор порта на хосте, просмотр логов и ссылки **`tg://proxy`** (хост из **`MTPRO_PUBLIC_HOST`**, **`CLIENT_CONFIG_ENDPOINT`** или из URL страницы панели).

View File

@@ -11,6 +11,7 @@
| **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). | | **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). |
| **Telegram MTProtoпрокси** | *Необязательно*, не часть AWG. Docker-контейнер официального образа **telegrammessenger/proxy**; установка из панели, ссылка **`tg://proxy`**, переменные **`MTPRO_*`** — см. основной [README](../README.md). | | **Telegram MTProtoпрокси** | *Необязательно*, не часть AWG. Docker-контейнер официального образа **telegrammessenger/proxy**; установка из панели, ссылка **`tg://proxy`**, переменные **`MTPRO_*`** — см. основной [README](../README.md). |
| **Новый клиент под каскад** | Клиент в конфиге смотрит на **промежуточный узел** по Endpoint; peer и ключи создаются **на текущем VPS** и отдаются в `.conf`. На промежуточном сервере заранее нужен проброс порта DNAT UDP до WG этого VPS ([**kaskad_web_vpn**](https://github.com/andrey271192/kaskad_web_vpn) или свой проброс); в блоке см. текст и команду установки каскад-панели. | | **Новый клиент под каскад** | Клиент в конфиге смотрит на **промежуточный узел** по Endpoint; peer и ключи создаются **на текущем VPS** и отдаются в `.conf`. На промежуточном сервере заранее нужен проброс порта DNAT UDP до WG этого VPS ([**kaskad_web_vpn**](https://github.com/andrey271192/kaskad_web_vpn) или свой проброс); в блоке см. текст и команду установки каскад-панели. |
| **Импорт клиента** | Перенос готового клиента из `.conf` или JSON backup Amnezia, если backup содержит текстовый конфиг. Панель восстанавливает строку в `clientsTable`, сохраняет `last_config` и добавляет peer только если его ещё нет в серверном конфиге. |
| **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. | | **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. |
## Переключатель «Инстанс» не отображается ## Переключатель «Инстанс» не отображается
@@ -61,6 +62,7 @@
- **Старые строки без `last_config`** — полный `.conf` с сервера собрать нельзя (нет приватного ключа). Используйте приложение Amnezia или блок **«Новый клиент под каскад»** (новый ключ на сервере). - **Старые строки без `last_config`** — полный `.conf` с сервера собрать нельзя (нет приватного ключа). Используйте приложение Amnezia или блок **«Новый клиент под каскад»** (новый ключ на сервере).
- **Экспорт по кнопкам** — только если в `clientsTable` есть **`userData.last_config`** с полем `config` или `client_priv_key`. - **Экспорт по кнопкам** — только если в `clientsTable` есть **`userData.last_config`** с полем `config` или `client_priv_key`.
- **Импорт из приложения Amnezia** — откройте блок **«Импорт клиента»**, выберите нужный **«Инстанс»**, вставьте клиентский `.conf` или JSON backup, где внутри есть такой `.conf`, и нажмите импорт. Если peer уже есть в `awg0.conf`, панель не создаёт дубль, а только восстанавливает/обновляет строку в `clientsTable`.
### Экспорт: имя файла и прямая ссылка ### Экспорт: имя файла и прямая ссылка
@@ -89,6 +91,7 @@
| POST | `/api/clients/disconnect-date` | Даты отключения / расписание | | POST | `/api/clients/disconnect-date` | Даты отключения / расписание |
| GET/POST | `/api/clients/export-config` | Скачать `.conf`; GET — прямая ссылка (сессия); опционально `?token=…` если задан `EXPORT_CONFIG_SECRET` | | GET/POST | `/api/clients/export-config` | Скачать `.conf`; GET — прямая ссылка (сессия); опционально `?token=…` если задан `EXPORT_CONFIG_SECRET` |
| POST | `/api/clients/create-cascade` | `{ "endpointHost", "endpointPort?", "tunnelIp?", "clientName?", "profileId?" }` — новый peer и файл `.conf` | | POST | `/api/clients/create-cascade` | `{ "endpointHost", "endpointPort?", "tunnelIp?", "clientName?", "profileId?" }` — новый peer и файл `.conf` |
| POST | `/api/clients/import-config` | `{ "configText", "clientName?", "profileId?" }` — импорт клиента из `.conf` или JSON backup с вложенным `.conf` |
| POST | `/api/warp/host-setup` | Установка/удаление WARP на хосте по SSH: `{ "rootPassword", "cmd": "install" \| "uninstall" }` (как синхронизация времени; каталог скрипта — `WARP_SSH_INSTALL_DIR`) | | POST | `/api/warp/host-setup` | Установка/удаление WARP на хосте по SSH: `{ "rootPassword", "cmd": "install" \| "uninstall" }` (как синхронизация времени; каталог скрипта — `WARP_SSH_INSTALL_DIR`) |
| POST | `/api/warp/start` | Поднять WARP | | POST | `/api/warp/start` | Поднять WARP |
| POST | `/api/warp/stop` | Остановить WARP | | POST | `/api/warp/stop` | Остановить WARP |

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.30", "version": "1.2.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.30", "version": "1.2.31",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.21.2" "express": "^4.21.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.30", "version": "1.2.31",
"private": false, "private": false,
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
"license": "MIT", "license": "MIT",

View File

@@ -658,6 +658,11 @@ if (directForm) {
directForm.addEventListener("submit", (ev) => void downloadDirectConf(ev)); directForm.addEventListener("submit", (ev) => void downloadDirectConf(ev));
} }
const importForm = document.querySelector("#import-form");
if (importForm) {
importForm.addEventListener("submit", (ev) => void importClientConfig(ev));
}
const instanceForm = document.querySelector("#instance-form"); const instanceForm = document.querySelector("#instance-form");
if (instanceForm) { if (instanceForm) {
instanceForm.addEventListener("submit", (ev) => void createInstance(ev)); instanceForm.addEventListener("submit", (ev) => void createInstance(ev));
@@ -1424,6 +1429,36 @@ async function downloadDirectConf(ev) {
} }
} }
async function importClientConfig(ev) {
ev.preventDefault();
const nameEl = document.querySelector("#import-name");
const configEl = document.querySelector("#import-config");
const configText = configEl?.value.trim() || "";
if (!configText) {
setStatus("Вставьте .conf или JSON backup для импорта.", true);
return;
}
const body = { configText };
const nm = nameEl?.value.trim();
if (nm) body.clientName = nm;
const pid = currentProfileIdValue();
if (pid) body.profileId = pid;
try {
setStatus("Импортирую клиента в текущий инстанс…", false);
const data = await api("/api/clients/import-config", {
method: "POST",
body: JSON.stringify(body),
});
const count = Array.isArray(data.imported) ? data.imported.length : 0;
if (configEl) configEl.value = "";
if (nameEl) nameEl.value = "";
setStatus(`Импортировано клиентов: ${count}. Таблица обновлена.`, false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}
const VARIANT_META = { const VARIANT_META = {
awg2: { icon: "✨", title: "AmneziaWG 2.0", badge: "NEW" }, awg2: { icon: "✨", title: "AmneziaWG 2.0", badge: "NEW" },
awg: { icon: "🔮", title: "AmneziaWG", badge: "" }, awg: { icon: "🔮", title: "AmneziaWG", badge: "" },

View File

@@ -240,6 +240,33 @@
</div> </div>
</details> </details>
<details class="panel-fold cascade-panel" id="import-panel">
<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">Перенос из готового .conf или backup Amnezia, где внутри есть конфиг</span>
</span>
</summary>
<div class="panel-fold-body">
<div class="muted cascade-guide">
<p class="cascade-intro">
Вставьте содержимое клиентского <code class="inline">.conf</code> или JSON backup. Панель добавит peer в текущий инстанс,
восстановит запись в <code class="inline">clientsTable</code> и сохранит исходный конфиг для дальнейшего экспорта.
</p>
</div>
<form id="import-form" class="cascade-form import-form">
<label for="import-name">Имя клиента / префикс</label>
<input id="import-name" type="text" placeholder="Например iPhone из backup" autocomplete="off">
<label for="import-config">.conf или JSON backup</label>
<textarea id="import-config" rows="10" spellcheck="false" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.8.1.10/32&#10;&#10;[Peer]&#10;PublicKey = ..."></textarea>
<button type="submit" class="btn primary">Импортировать в текущий инстанс</button>
</form>
</div>
</details>
<details class="panel-fold cascade-panel" id="cascade-panel" open> <details class="panel-fold cascade-panel" id="cascade-panel" open>
<summary class="fold-summary"> <summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span> <span class="fold-arrow" aria-hidden="true"></span>

View File

@@ -844,10 +844,22 @@ tr:last-child td {
color: var(--muted); color: var(--muted);
} }
.cascade-form input { .cascade-form input,
.cascade-form textarea {
width: 100%; width: 100%;
} }
.cascade-form textarea {
resize: vertical;
min-height: 12rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
line-height: 1.45;
}
.import-form {
max-width: 46rem;
}
.raw { .raw {
margin-top: 1.25rem; margin-top: 1.25rem;
color: var(--muted); color: var(--muted);

211
server.js
View File

@@ -998,6 +998,189 @@ function parseInterfaceKeyValues(head) {
return out; return out;
} }
function parseWireGuardSections(text) {
const sections = {};
let current = null;
for (const rawLine of String(text).replace(/\r\n/g, "\n").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
const section = line.match(/^\[([^\]]+)\]$/);
if (section) {
current = section[1].trim().toLowerCase();
sections[current] ||= {};
continue;
}
if (!current) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
sections[current][key] = value;
}
return sections;
}
function looksLikeWireGuardConfig(text) {
const s = String(text || "");
return /\[Interface\]/i.test(s) && /\[Peer\]/i.test(s) && /PrivateKey\s*=/i.test(s);
}
function extractWireGuardConfigsFromText(raw) {
const text = String(raw || "").trim();
if (!text) return [];
if (looksLikeWireGuardConfig(text)) return [text];
const found = [];
const seen = new Set();
const visit = (value) => {
if (typeof value === "string") {
const s = value.trim();
if (looksLikeWireGuardConfig(s) && !seen.has(s)) {
seen.add(s);
found.push(s);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) visit(item);
}
};
try {
visit(JSON.parse(text));
} catch {
return [];
}
return found;
}
function normalizeImportName(raw, fallback) {
const s = String(raw || "").trim().replace(/\s+/g, " ").slice(0, 200);
return s || fallback;
}
function parseClientAddressList(addressRaw) {
const parts = String(addressRaw || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (!parts.length) throw new Error("В [Interface] импортируемого конфига нет Address.");
return parts.map((part) => {
const ip = part.split("/")[0].trim();
if (!parseIpv4ToParts(ip)) return part;
return part.includes("/") ? part : `${ip}/32`;
});
}
function splitEndpointHostPort(endpoint) {
const raw = String(endpoint || "").trim();
if (!raw) return {};
const m = raw.match(/^(.+):(\d{1,5})$/);
if (!m) return { hostName: raw };
const port = Number(m[2]);
return {
hostName: m[1].replace(/^\[|\]$/g, ""),
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined,
};
}
function buildImportedLastConfig(clientConf, sections, allowedIps) {
const iface = sections.interface || {};
const peer = sections.peer || {};
const endpoint = splitEndpointHostPort(peer.Endpoint);
const out = {
config: clientConf.trim(),
client_priv_key: iface.PrivateKey,
server_pub_key: peer.PublicKey,
client_ip: allowedIps[0].replace(/\/\d+$/, ""),
allowed_ips: String(peer.AllowedIPs || "0.0.0.0/0, ::/0")
.split(",")
.map((x) => x.trim())
.filter(Boolean),
...endpoint,
};
if (peer.PresharedKey) out.psk_key = peer.PresharedKey;
if (iface.DNS) out.dns = iface.DNS;
if (iface.MTU) out.mtu = iface.MTU;
for (const k of ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]) {
if (iface[k]) out[k] = iface[k];
}
return out;
}
async function importClientConfigIntoRuntime(rt, { clientConf, clientName }) {
const normalized = String(clientConf || "").trim();
if (!looksLikeWireGuardConfig(normalized)) {
throw new Error("Не найден WireGuard/AmneziaWG .conf: нужны секции [Interface] и [Peer].");
}
const sections = parseWireGuardSections(normalized);
const iface = sections.interface || {};
const peer = sections.peer || {};
if (!iface.PrivateKey) throw new Error("В [Interface] нет PrivateKey клиента.");
const allowedIps = parseClientAddressList(iface.Address);
const pub = await wgPubkeyFromPrivate(rt, iface.PrivateKey);
const pskLine = peer.PresharedKey ? `PresharedKey = ${peer.PresharedKey}\n` : "";
const serverPeerRaw = `[Peer]
PublicKey = ${pub}
${pskLine}AllowedIPs = ${allowedIps.join(", ")}
`;
const serverPeer = parsePeerBlock(`${serverPeerRaw}\n`);
await rt.backupRemoteFiles();
const { conf, clients, peerByKey } = await rt.loadState();
const serverIface = parseInterfaceKeyValues(conf.head);
if (peer.PublicKey && serverIface.PrivateKey) {
const currentServerPub = await wgPubkeyFromPrivate(rt, serverIface.PrivateKey);
if (String(peer.PublicKey).trim() !== currentServerPub) {
throw new Error(
"Этот клиентский .conf относится к другому серверу/инстансу. Выберите правильный «Инстанс» или импортируйте конфиг от текущего сервера.",
);
}
}
const existingClientIdx = clients.findIndex((c) => c.clientId === pub);
const last_config = JSON.stringify(buildImportedLastConfig(normalized, sections, allowedIps));
const now = new Date().toISOString();
const name = normalizeImportName(clientName, `Импорт ${allowedIps[0].replace(/\/\d+$/, "")}`);
const nextPeers = peerByKey.has(pub) ? conf.peers : [...conf.peers, serverPeer];
const nextClients = [...clients];
const userData = {
...(existingClientIdx >= 0 ? clients[existingClientIdx].userData || {} : {}),
clientName: name,
last_config,
allowedIps: allowedIps.join(", "),
importedAt: now,
};
delete userData.disabled;
delete userData.disabledAt;
delete userData.scheduledTunnelDisconnectAt;
const rowPatch = {
clientId: pub,
userData,
};
if (existingClientIdx >= 0) {
nextClients[existingClientIdx] = { ...clients[existingClientIdx], ...rowPatch };
} else {
rowPatch.userData.creationDate = now;
nextClients.push(rowPatch);
}
await rt.dockerWriteFile(rt.confPath, serializeAwgConf(conf.head, nextPeers));
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
await rt.applySyncconf();
return {
clientId: pub,
name,
allowedIps,
peerAdded: !peerByKey.has(pub),
tableUpdated: true,
};
}
/** Имя файла только из ASCII — иначе Node отклоняет заголовок Content-Disposition. */ /** Имя файла только из ASCII — иначе Node отклоняет заголовок Content-Disposition. */
function safeExportFilenamePart(name, fallback) { function safeExportFilenamePart(name, fallback) {
const toAsciiToken = (s) => const toAsciiToken = (s) =>
@@ -1637,7 +1820,7 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto)
if (IS_COMMUNITY) { if (IS_COMMUNITY) {
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`); console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
} }
app.use(express.json({ limit: "512kb" })); app.use(express.json({ limit: "2mb" }));
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ ok: true, version: PANEL_VERSION }); res.json({ ok: true, version: PANEL_VERSION });
@@ -2382,6 +2565,32 @@ AllowedIPs = ${tunnelIp}/32
} }
}); });
app.post("/api/clients/import-config", requireAuth, requireProTier, async (req, res) => {
const rt = runtimeFromExportRequest(req);
try {
const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? "";
const configs = extractWireGuardConfigsFromText(raw);
if (!configs.length) {
res.status(400).json({
error:
"Не нашёл .conf в тексте. Вставьте WireGuard/AmneziaWG конфиг с секциями [Interface] и [Peer] или JSON backup, где такой конфиг хранится строкой.",
});
return;
}
const limit = Math.min(configs.length, 20);
const prefix = normalizeImportName(req.body?.clientName || req.body?.namePrefix, "Импорт");
const imported = [];
for (let i = 0; i < limit; i++) {
const name = configs.length === 1 ? prefix : `${prefix} ${i + 1}`;
imported.push(await importClientConfigIntoRuntime(rt, { clientConf: configs[i], clientName: name }));
}
res.json({ ok: true, found: configs.length, imported });
} catch (e) {
console.error(e);
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => { app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
if (UI_HIDDEN.warp) { if (UI_HIDDEN.warp) {
return res.status(403).json({ error: MSG_UI_WARP_OFF }); return res.status(403).json({ error: MSG_UI_WARP_OFF });