feat: import clients from amnezia configs

This commit is contained in:
andrey271192
2026-06-21 18:22:36 +03:00
parent 8ba2a266e5
commit cff27e27b6
8 changed files with 305 additions and 5 deletions

211
server.js
View File

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