mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
feat: import clients from amnezia configs
This commit is contained in:
14
README.md
14
README.md
@@ -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 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‑прокси
|
||||
|
||||
**Не связано с AmneziaWG.** Отдельный контейнер образа **[telegrammessenger/proxy](https://hub.docker.com/r/telegrammessenger/proxy)** на том же хосте. После входа в панель: установка, перезапуск, удаление, выбор порта на хосте, просмотр логов и ссылки **`tg://proxy`** (хост из **`MTPRO_PUBLIC_HOST`**, **`CLIENT_CONFIG_ENDPOINT`** или из URL страницы панели).
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
| **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). |
|
||||
| **Новый клиент под каскад** | Клиент в конфиге смотрит на **промежуточный узел** по 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`. |
|
||||
|
||||
## Переключатель «Инстанс» не отображается
|
||||
@@ -61,6 +62,7 @@
|
||||
|
||||
- **Старые строки без `last_config`** — полный `.conf` с сервера собрать нельзя (нет приватного ключа). Используйте приложение Amnezia или блок **«Новый клиент под каскад»** (новый ключ на сервере).
|
||||
- **Экспорт по кнопкам** — только если в `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` | Даты отключения / расписание |
|
||||
| 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/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/start` | Поднять WARP |
|
||||
| POST | `/api/warp/stop` | Остановить WARP |
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "amnezia-admin",
|
||||
"version": "1.2.30",
|
||||
"version": "1.2.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "amnezia-admin",
|
||||
"version": "1.2.30",
|
||||
"version": "1.2.31",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amnezia-admin",
|
||||
"version": "1.2.30",
|
||||
"version": "1.2.31",
|
||||
"private": false,
|
||||
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -658,6 +658,11 @@ if (directForm) {
|
||||
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");
|
||||
if (instanceForm) {
|
||||
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 = {
|
||||
awg2: { icon: "✨", title: "AmneziaWG 2.0", badge: "NEW" },
|
||||
awg: { icon: "🔮", title: "AmneziaWG", badge: "" },
|
||||
|
||||
@@ -240,6 +240,33 @@
|
||||
</div>
|
||||
</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] PrivateKey = ... Address = 10.8.1.10/32 [Peer] PublicKey = ..."></textarea>
|
||||
|
||||
<button type="submit" class="btn primary">Импортировать в текущий инстанс</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold cascade-panel" id="cascade-panel" open>
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
|
||||
@@ -844,10 +844,22 @@ tr:last-child td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cascade-form input {
|
||||
.cascade-form input,
|
||||
.cascade-form textarea {
|
||||
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 {
|
||||
margin-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
|
||||
211
server.js
211
server.js
@@ -998,6 +998,189 @@ function parseInterfaceKeyValues(head) {
|
||||
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. */
|
||||
function safeExportFilenamePart(name, fallback) {
|
||||
const toAsciiToken = (s) =>
|
||||
@@ -1637,7 +1820,7 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto)
|
||||
if (IS_COMMUNITY) {
|
||||
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
|
||||
}
|
||||
app.use(express.json({ limit: "512kb" }));
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
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) => {
|
||||
if (UI_HIDDEN.warp) {
|
||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||
|
||||
Reference in New Issue
Block a user