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:
Андрей Бобырев
2026-05-14 18:27:10 +03:00
parent ecb64b8b33
commit 75a3e56354
4 changed files with 339 additions and 1 deletions

View File

@@ -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 **сами не попадают** в уже запущенный контейнер: нужно заново **скачать код и пересобрать образ**.

View File

@@ -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, "&quot;"); .replace(/"/g, "&quot;");
} }
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;

View File

@@ -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
View File

@@ -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))) {