mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
feat: export client .conf from last_config
Adds POST /api/clients/export-config, UI button when exportAvailable, CLIENT_* env for Endpoint/DNS in install.sh. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
11
README.md
11
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Amnezia Admin WebUI
|
# Amnezia Admin WebUI
|
||||||
|
|
||||||
Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения, а также **политика выхода через Cloudflare WARP** по клиентам (без Telegram и без QR — только скрипт установки на хосте и блок в вебе). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`).
|
Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения, при наличии `last_config` на сервере — **скачивание клиентского .conf**, а также **политика выхода через Cloudflare WARP** по клиентам (без Telegram и без QR — только скрипт установки на хосте и блок в вебе). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`).
|
||||||
|
|
||||||
**Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS.
|
**Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS.
|
||||||
|
|
||||||
@@ -57,6 +57,9 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов |
|
| `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов |
|
||||||
| `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) |
|
| `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) |
|
||||||
| `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH |
|
| `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH |
|
||||||
|
| `CLIENT_CONFIG_ENDPOINT` | _(нет)_ | Публичный IP или DNS VPS для строки `Endpoint` при сборке `.conf` из `last_config`, если нет `hostName` в JSON и заходите в панель не по IP |
|
||||||
|
| `CLIENT_EXPORT_DNS1` | `8.8.8.8` | DNS в экспортируемом клиентском конфиге |
|
||||||
|
| `CLIENT_EXPORT_DNS2` | `8.8.4.4` | Второй DNS в экспортируемом конфиге |
|
||||||
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
||||||
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
||||||
| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже |
|
| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже |
|
||||||
@@ -99,6 +102,12 @@ chmod +x scripts/warp-amnezia.sh
|
|||||||
|
|
||||||
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.
|
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.
|
||||||
|
|
||||||
|
### Экспорт конфигурации клиента (.conf)
|
||||||
|
|
||||||
|
Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появится кнопка **«Скачать .conf»**. Такое содержимое сервер получает, когда клиент создавался/синхронизировался через официальное приложение; если на VPS только «голый» `clientsTable` без `last_config`, кнопки не будет — конфиг нужно брать из приложения на устройстве.
|
||||||
|
|
||||||
|
Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`).
|
||||||
|
|
||||||
### Обновление до новой версии
|
### Обновление до новой версии
|
||||||
|
|
||||||
Коммиты на GitHub **сами не попадают** в уже запущенный контейнер: нужно заново **скачать код и пересобрать образ**.
|
Коммиты на GitHub **сами не попадают** в уже запущенный контейнер: нужно заново **скачать код и пересобрать образ**.
|
||||||
|
|||||||
@@ -548,6 +548,11 @@ function renderRows(clients) {
|
|||||||
btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId))
|
btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (c.exportAvailable) {
|
||||||
|
actTd.appendChild(
|
||||||
|
btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c)),
|
||||||
|
);
|
||||||
|
}
|
||||||
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
||||||
|
|
||||||
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
||||||
@@ -703,6 +708,45 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadClientConfig(c) {
|
||||||
|
try {
|
||||||
|
setStatus("Готовлю конфиг…", false);
|
||||||
|
const res = await fetch("/api/clients/export-config", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientId: c.clientId }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = typeof j.error === "string" ? j.error : msg;
|
||||||
|
} catch {
|
||||||
|
/* сырой текст */
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const safe = String(c.name || "client")
|
||||||
|
.replace(/[^\w\u0400-\u04FF\-]+/g, "_")
|
||||||
|
.slice(0, 60);
|
||||||
|
a.href = url;
|
||||||
|
a.download = `amnezia-${safe}.conf`;
|
||||||
|
a.rel = "noopener";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("Конфиг скачан.", false);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renameClient(c) {
|
async function renameClient(c) {
|
||||||
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
|
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
|
||||||
if (next === null) return;
|
if (next === null) return;
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do
|
||||||
|
if [[ -n "${!__export_var:-}" ]]; then
|
||||||
|
RUN_ENV+=( -e "${__export_var}=${!__export_var}" )
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -n "${BOOT_PW}" ]]; then
|
if [[ -n "${BOOT_PW}" ]]; then
|
||||||
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
||||||
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
||||||
|
|||||||
279
server.js
279
server.js
@@ -697,6 +697,243 @@ function stringifyClientsTable(rows) {
|
|||||||
return `${JSON.stringify(rows, null, 4)}\n`;
|
return `${JSON.stringify(rows, null, 4)}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */
|
||||||
|
const AWG_EXPORT_DEFAULTS = {
|
||||||
|
Jc: "3",
|
||||||
|
Jmin: "10",
|
||||||
|
Jmax: "30",
|
||||||
|
S1: "15",
|
||||||
|
S2: "18",
|
||||||
|
S3: "20",
|
||||||
|
S4: "23",
|
||||||
|
H1: "1020325451",
|
||||||
|
H2: "3288052141",
|
||||||
|
H3: "1766607858",
|
||||||
|
H4: "2528465083",
|
||||||
|
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
|
||||||
|
I2: "",
|
||||||
|
I3: "",
|
||||||
|
I4: "",
|
||||||
|
I5: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseLastConfigFromClientRow(row) {
|
||||||
|
const ud = row?.userData;
|
||||||
|
if (!ud || typeof ud !== "object") return null;
|
||||||
|
let raw = ud.last_config ?? ud.lastConfig;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
raw = raw.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === "object") return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientHasExportableLastConfig(row) {
|
||||||
|
const lc = parseLastConfigFromClientRow(row);
|
||||||
|
if (!lc) return false;
|
||||||
|
if (String(lc.config ?? lc.nativeConfig ?? "").trim()) return true;
|
||||||
|
const priv = lc.client_priv_key || lc.clientPrivKey;
|
||||||
|
return Boolean(priv && typeof priv === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLc(lc, ...keys) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = lc[k];
|
||||||
|
if (v != null && v !== "") return v;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInterfaceKeyValues(head) {
|
||||||
|
const out = {};
|
||||||
|
for (const line of String(head).split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t || t.startsWith("#")) continue;
|
||||||
|
const eq = t.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const k = t.slice(0, eq).trim();
|
||||||
|
const v = t.slice(eq + 1).trim();
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeExportFilenamePart(name, fallback) {
|
||||||
|
const s = String(name || fallback || "client").replace(/[^\w\u0400-\u04FF\-]+/g, "_");
|
||||||
|
return s.slice(0, 80) || "client";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportAllowedIps(lc, fallback = "0.0.0.0/0, ::/0") {
|
||||||
|
const v = lc.allowed_ips ?? lc.allowedIps;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const joined = v.map(String).join(", ");
|
||||||
|
return joined.trim() || fallback;
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && v.trim()) return v.trim();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wgPubkeyFromPrivate(rt, privKeyB64) {
|
||||||
|
const key = String(privKeyB64).trim();
|
||||||
|
if (!/^[A-Za-z0-9+/=_-]+$/.test(key)) {
|
||||||
|
throw new Error("Некорректный формат приватного ключа сервера в awg0.conf");
|
||||||
|
}
|
||||||
|
const q = key.replace(/'/g, `'\\''`);
|
||||||
|
const out = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`);
|
||||||
|
const pub = out.trim().split(/\s+/)[0];
|
||||||
|
if (!pub) throw new Error("Не удалось получить публичный ключ сервера (wg pubkey).");
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelClientIpv4(peer, lc, row) {
|
||||||
|
const fromLc = pickLc(lc, "client_ip", "clientIp");
|
||||||
|
if (fromLc) return String(fromLc).replace(/\/\d+$/, "").trim();
|
||||||
|
if (peer?.allowedIPs) {
|
||||||
|
const m = String(peer.allowedIPs).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
const ud = row?.userData || {};
|
||||||
|
const udIp = ud.allowedIps || ud.preservedAllowedIPs;
|
||||||
|
if (udIp) {
|
||||||
|
const m = String(udIp).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Нет client_ip в last_config и не удалось взять IPv4 из AllowedIPs peer или из записи клиента (выключен без сохранённого адреса).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExportEndpointHost(lc, req) {
|
||||||
|
const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim();
|
||||||
|
if (env) return env;
|
||||||
|
const hn = pickLc(lc, "hostName", "hostname", "host");
|
||||||
|
if (hn && String(hn).trim()) return String(hn).trim();
|
||||||
|
const h = req.headers.host;
|
||||||
|
if (h && typeof h === "string") {
|
||||||
|
const hostPart = h.split(":")[0].trim();
|
||||||
|
if (hostPart && hostPart !== "localhost") return hostPart;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Не удалось определить Endpoint. Задайте CLIENT_CONFIG_ENDPOINT для контейнера панели (публичный IP или DNS VPS) или hostName в last_config клиента.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) {
|
||||||
|
const native = String(lc.config ?? lc.nativeConfig ?? "").trim();
|
||||||
|
if (native) return native;
|
||||||
|
|
||||||
|
const priv = pickLc(lc, "client_priv_key", "clientPrivKey");
|
||||||
|
if (!priv || typeof priv !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"В last_config нет готового текста (config) и нет client_priv_key — восстановить .conf с сервера нельзя.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = conf.peers.find((p) => p.publicKey === row.clientId);
|
||||||
|
const tunnelIp = tunnelClientIpv4(peer || {}, lc, row);
|
||||||
|
|
||||||
|
let serverPub = pickLc(lc, "server_pub_key", "serverPubKey");
|
||||||
|
if (!serverPub && ifaceMap.PrivateKey) {
|
||||||
|
serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey);
|
||||||
|
}
|
||||||
|
if (!serverPub) {
|
||||||
|
throw new Error("Нет server_pub_key в last_config и PrivateKey в секции [Interface] сервера.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const psk = pickLc(lc, "psk_key", "pskKey");
|
||||||
|
if (!psk || typeof psk !== "string") {
|
||||||
|
throw new Error("В last_config нет psk_key (общий ключ с сервером).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointHost = resolveExportEndpointHost(lc, req);
|
||||||
|
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
|
||||||
|
const portNum = Number(pickLc(lc, "port")) || (Number.isFinite(listenPort) ? listenPort : NaN);
|
||||||
|
const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820;
|
||||||
|
const port = Number.isFinite(portNum) && portNum > 0 ? portNum : defaultPort;
|
||||||
|
|
||||||
|
const dns1 = String(pickLc(lc, "dns1") || process.env.CLIENT_EXPORT_DNS1?.trim() || "8.8.8.8");
|
||||||
|
const dns2 = String(pickLc(lc, "dns2") || process.env.CLIENT_EXPORT_DNS2?.trim() || "8.8.4.4");
|
||||||
|
const peerAllowed = formatExportAllowedIps(lc);
|
||||||
|
const keepAlive = String(pickLc(lc, "persistent_keep_alive", "persistentKeepAlive") || "25");
|
||||||
|
const mtuVal = pickLc(lc, "mtu", "MTU");
|
||||||
|
const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : "";
|
||||||
|
|
||||||
|
if (rt.profile.wgBinary === "awg") {
|
||||||
|
const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc);
|
||||||
|
const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin);
|
||||||
|
const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax);
|
||||||
|
const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1);
|
||||||
|
const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2);
|
||||||
|
const S3 = String(
|
||||||
|
pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3,
|
||||||
|
);
|
||||||
|
const S4 = String(
|
||||||
|
pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4,
|
||||||
|
);
|
||||||
|
const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1);
|
||||||
|
const H2 = String(
|
||||||
|
pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2,
|
||||||
|
);
|
||||||
|
const H3 = String(
|
||||||
|
pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3,
|
||||||
|
);
|
||||||
|
const H4 = String(
|
||||||
|
pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4,
|
||||||
|
);
|
||||||
|
const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1);
|
||||||
|
const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2);
|
||||||
|
const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3);
|
||||||
|
const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4);
|
||||||
|
const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5);
|
||||||
|
|
||||||
|
return `[Interface]
|
||||||
|
Address = ${tunnelIp}/32
|
||||||
|
DNS = ${dns1}, ${dns2}
|
||||||
|
PrivateKey = ${priv.trim()}
|
||||||
|
Jc = ${Jc}
|
||||||
|
Jmin = ${Jmin}
|
||||||
|
Jmax = ${Jmax}
|
||||||
|
S1 = ${S1}
|
||||||
|
S2 = ${S2}
|
||||||
|
S3 = ${S3}
|
||||||
|
S4 = ${S4}
|
||||||
|
H1 = ${H1}
|
||||||
|
H2 = ${H2}
|
||||||
|
H3 = ${H3}
|
||||||
|
H4 = ${H4}
|
||||||
|
I1 = ${I1}
|
||||||
|
I2 = ${I2}
|
||||||
|
I3 = ${I3}
|
||||||
|
I4 = ${I4}
|
||||||
|
I5 = ${I5}
|
||||||
|
${mtuLine}[Peer]
|
||||||
|
PublicKey = ${String(serverPub).trim()}
|
||||||
|
PresharedKey = ${String(psk).trim()}
|
||||||
|
AllowedIPs = ${peerAllowed}
|
||||||
|
Endpoint = ${endpointHost}:${port}
|
||||||
|
PersistentKeepalive = ${keepAlive}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[Interface]
|
||||||
|
Address = ${tunnelIp}/32
|
||||||
|
DNS = ${dns1}, ${dns2}
|
||||||
|
PrivateKey = ${priv.trim()}
|
||||||
|
${mtuLine}[Peer]
|
||||||
|
PublicKey = ${String(serverPub).trim()}
|
||||||
|
PresharedKey = ${String(psk).trim()}
|
||||||
|
AllowedIPs = ${peerAllowed}
|
||||||
|
Endpoint = ${endpointHost}:${port}
|
||||||
|
PersistentKeepalive = ${keepAlive}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function disableClient(rt, clientId, ts) {
|
async function disableClient(rt, clientId, ts) {
|
||||||
await rt.backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
const { conf, clients } = await rt.loadState();
|
const { conf, clients } = await rt.loadState();
|
||||||
@@ -1076,6 +1313,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
Boolean(warpMeta.supported && warpMeta.installed) &&
|
Boolean(warpMeta.supported && warpMeta.installed) &&
|
||||||
activeInConf &&
|
activeInConf &&
|
||||||
peerUsesWarp(peer, warpSelected),
|
peerUsesWarp(peer, warpSelected),
|
||||||
|
exportAvailable: clientHasExportableLastConfig(c),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const warpOut =
|
const warpOut =
|
||||||
@@ -1106,6 +1344,47 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/clients/export-config", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
|
const clientId = req.body?.clientId;
|
||||||
|
if (!clientId || typeof clientId !== "string") {
|
||||||
|
res.status(400).json({ error: "Укажите clientId" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { conf, clients } = await rt.loadState();
|
||||||
|
const row = clients.find((c) => c.clientId === clientId);
|
||||||
|
if (!row) {
|
||||||
|
res.status(404).json({ error: "Клиент не найден в clientsTable" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lc = parseLastConfigFromClientRow(row);
|
||||||
|
if (!lc) {
|
||||||
|
res.status(404).json({
|
||||||
|
error:
|
||||||
|
"На сервере нет userData.last_config для этого клиента. Полный конфиг хранится в приложении Amnezia на устройстве, где ключ создавали (или синхронизируйте клиентов с сервером из приложения).",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ifaceMap = parseInterfaceKeyValues(conf.head);
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = await buildClientConfExport(rt, lc, ifaceMap, req, row, conf);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: String(e.message || e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ud = row.userData || {};
|
||||||
|
const baseName = safeExportFilenamePart(ud.clientName, clientId.slice(0, 12));
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="amnezia-${baseName}.conf"`);
|
||||||
|
res.send(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(req);
|
||||||
if (!(await warpFileExists(rt, rt.profile.warpConf))) {
|
if (!(await warpFileExists(rt, rt.profile.warpConf))) {
|
||||||
|
|||||||
Reference in New Issue
Block a user