mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
feat: direct client creation + auto-detect AWG container
Add "Новый клиент" (direct) flow alongside cascade: - server.js: POST /api/clients/create — Endpoint = this server's public IP:ListenPort (CLIENT_CONFIG_ENDPOINT or request host), reuses the same key/peer/conf pipeline as create-cascade. - public/index.html: "Новый клиент" panel with #direct-form (name + optional tunnel IP). - public/app.js: downloadDirectConf() handler + form binding. install.sh hardening so fresh installs work out of the box: - Auto-detect a single amnezia-awg* container (e.g. amnezia-awg2, Amnezia AWG 2.0 default) -> AWG_CONTAINER, instead of the fixed "amnezia-awg" default that mismatched and blocked client ops. - Auto-default CLIENT_CONFIG_ENDPOINT to the host primary IP so direct .conf exports get a correct Endpoint without manual env. Fixes "cannot create users" on servers whose WG container is named amnezia-awg2.
This commit is contained in:
@@ -651,6 +651,11 @@ if (cascadeForm) {
|
||||
cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev));
|
||||
}
|
||||
|
||||
const directForm = document.querySelector("#direct-form");
|
||||
if (directForm) {
|
||||
directForm.addEventListener("submit", (ev) => void downloadDirectConf(ev));
|
||||
}
|
||||
|
||||
protoSelect.addEventListener("change", async () => {
|
||||
try {
|
||||
setStatus("Смена инстанса…", false);
|
||||
@@ -1349,6 +1354,58 @@ async function downloadCascadeConf(ev) {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadDirectConf(ev) {
|
||||
ev.preventDefault();
|
||||
const nameEl = document.querySelector("#direct-name");
|
||||
const tunnelEl = document.querySelector("#direct-tunnel-ip");
|
||||
const body = {};
|
||||
const nm = nameEl?.value.trim();
|
||||
if (nm) body.clientName = nm;
|
||||
const tip = tunnelEl?.value.trim();
|
||||
if (tip) body.tunnelIp = tip;
|
||||
const pid = currentProfileIdValue();
|
||||
if (pid) body.profileId = pid;
|
||||
try {
|
||||
setStatus("Создаю клиента на сервере и собираю .conf…", false);
|
||||
const res = await fetch("/api/clients/create", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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 {
|
||||
/* raw */
|
||||
}
|
||||
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 = (nm || "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);
|
||||
if (nameEl) nameEl.value = "";
|
||||
if (tunnelEl) tunnelEl.value = "";
|
||||
setStatus("Клиент добавлен на сервер, .conf скачан. Обновите таблицу.", false);
|
||||
await loadClients();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameClient(c) {
|
||||
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
|
||||
if (next === null) return;
|
||||
|
||||
@@ -183,6 +183,34 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold" id="direct-panel" open>
|
||||
<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">Прямое подключение к этому серверу — Endpoint = публичный IP этого VPS</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="panel-fold-body">
|
||||
<div class="muted cascade-guide">
|
||||
<p class="cascade-intro">
|
||||
<strong>Обычное подключение.</strong> Создаёт нового клиента AmneziaWG и сразу скачивает файл
|
||||
<code class="inline">.conf</code>, где <strong>Endpoint указывает на этот сервер напрямую</strong>.
|
||||
Импортируйте файл в приложение Amnezia на телефоне или ПК. IP в туннеле подберётся автоматически.
|
||||
</p>
|
||||
</div>
|
||||
<form id="direct-form" class="cascade-form">
|
||||
<label for="direct-name">Имя клиента</label>
|
||||
<input id="direct-name" type="text" placeholder="Например iPhone Андрей" autocomplete="off">
|
||||
|
||||
<label for="direct-tunnel-ip">IP в VPN-туннеле (необязательно)</label>
|
||||
<input id="direct-tunnel-ip" type="text" placeholder="Пусто — подберём свободный в подсети" autocomplete="off">
|
||||
|
||||
<button type="submit" class="btn primary">Создать и скачать .conf</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>
|
||||
|
||||
@@ -145,6 +145,17 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Авто-определение единственного контейнера amnezia-awg* (например amnezia-awg2 —
|
||||
# дефолт Amnezia для AWG 2.0), если AWG_CONTAINER и AWG_PROFILES не заданы вручную.
|
||||
if [[ -z "${AWG_CONTAINER:-}" ]] && [[ -z "${AWG_PROFILES:-}" ]]; then
|
||||
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^amnezia-awg' || true)"
|
||||
__awg_names_count="$(printf '%s\n' "${__awg_names}" | grep -c . || true)"
|
||||
if [[ "${__awg_names_count}" == "1" ]]; then
|
||||
AWG_CONTAINER="$(printf '%s\n' "${__awg_names}" | head -n1)"
|
||||
echo "→ AWG_CONTAINER авто-определён: ${AWG_CONTAINER}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${AWG_PROFILES:-}" ]]; then
|
||||
__awg_multi_count="$(
|
||||
docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { c++ } END { print c + 0 }' | tr -d '[:space:]'
|
||||
@@ -213,6 +224,14 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "${CLIENT_CONFIG_ENDPOINT:-}" ]]; then
|
||||
__pub_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
if [[ -n "${__pub_ip}" ]]; then
|
||||
CLIENT_CONFIG_ENDPOINT="${__pub_ip}"
|
||||
echo "→ CLIENT_CONFIG_ENDPOINT авто: ${CLIENT_CONFIG_ENDPOINT} (можно переопределить переменной окружения)"
|
||||
fi
|
||||
fi
|
||||
|
||||
for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 EXPORT_CONFIG_SECRET; do
|
||||
if [[ -n "${!__export_var:-}" ]]; then
|
||||
RUN_ENV+=( -e "${__export_var}=${!__export_var}" )
|
||||
|
||||
93
server.js
93
server.js
@@ -2042,6 +2042,99 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => {
|
||||
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
|
||||
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
|
||||
*/
|
||||
app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) => {
|
||||
const rt = runtimeFromExportRequest(req);
|
||||
try {
|
||||
await rt.backupRemoteFiles();
|
||||
const { conf, clients } = await rt.loadState();
|
||||
const ifaceMap = parseInterfaceKeyValues(conf.head);
|
||||
if (!ifaceMap.PrivateKey) {
|
||||
res.status(400).json({ error: "В wg/awg конфиге сервера нет PrivateKey в [Interface]." });
|
||||
return;
|
||||
}
|
||||
const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
|
||||
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
|
||||
const envHost = process.env.CLIENT_CONFIG_ENDPOINT?.trim();
|
||||
const hdrHost =
|
||||
typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : "";
|
||||
const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : "");
|
||||
if (!endpointHost) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
"Не удалось определить публичный адрес сервера. Задайте CLIENT_CONFIG_ENDPOINT (IP/DNS этого VPS) для контейнера панели.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820;
|
||||
const endpointPort =
|
||||
Number.isFinite(listenPort) && listenPort > 0 ? listenPort : defaultPort;
|
||||
const psk = await rt.inferPskFromConf(conf);
|
||||
if (!psk || typeof psk !== "string") {
|
||||
res.status(400).json({ error: "Не удалось определить PresharedKey (нет peer или файла psk)." });
|
||||
return;
|
||||
}
|
||||
const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey);
|
||||
const { priv, pub } = await awgGenKeypair(rt);
|
||||
if (clients.some((c) => c.clientId === pub)) {
|
||||
res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." });
|
||||
return;
|
||||
}
|
||||
const obf = obfuscationFieldsFromServerHead(ifaceMap);
|
||||
const lc = {
|
||||
client_priv_key: priv,
|
||||
server_pub_key: serverPub,
|
||||
psk_key: psk,
|
||||
client_ip: tunnelIp,
|
||||
hostName: endpointHost,
|
||||
port: endpointPort,
|
||||
allowed_ips: ["0.0.0.0/0", "::/0"],
|
||||
...obf,
|
||||
};
|
||||
const peerRaw = `[Peer]
|
||||
PublicKey = ${pub}
|
||||
PresharedKey = ${psk}
|
||||
AllowedIPs = ${tunnelIp}/32
|
||||
`;
|
||||
const peer = parsePeerBlock(`${peerRaw}\n`);
|
||||
const nextPeers = [...conf.peers, peer];
|
||||
const nextConfText = serializeAwgConf(conf.head, nextPeers);
|
||||
const rawName = req.body?.clientName;
|
||||
const clientName =
|
||||
typeof rawName === "string" && rawName.trim()
|
||||
? rawName.trim().replace(/\s+/g, " ").slice(0, 200)
|
||||
: `Клиент ${tunnelIp}`;
|
||||
const last_config = JSON.stringify(lc);
|
||||
const newRow = {
|
||||
clientId: pub,
|
||||
userData: {
|
||||
clientName,
|
||||
creationDate: new Date().toISOString(),
|
||||
last_config,
|
||||
allowedIps: `${tunnelIp}/32`,
|
||||
},
|
||||
};
|
||||
const nextClients = [...clients, newRow];
|
||||
await rt.dockerWriteFile(rt.confPath, nextConfText);
|
||||
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
|
||||
await rt.applySyncconf();
|
||||
const confAfter = { ...conf, peers: nextPeers };
|
||||
let text;
|
||||
try {
|
||||
text = await buildClientConfExport(rt, lc, ifaceMap, req, newRow, confAfter);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e.message || e) });
|
||||
return;
|
||||
}
|
||||
const baseName = safeExportFilenamePart(clientName, pub.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/clients/create-cascade", requireAuth, requireProTier, async (req, res) => {
|
||||
if (UI_HIDDEN.cascade) {
|
||||
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
||||
|
||||
Reference in New Issue
Block a user