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:
andrey271192
2026-06-16 19:30:11 +03:00
parent c202f0bda5
commit c49e7eea8d
4 changed files with 197 additions and 0 deletions

View File

@@ -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 });