mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
fix: preserve AWG_PROFILES on reinstall; GET export URL + token
- install.sh: snapshot /root/amnezia-admin.awg-profiles.json + restore from old container - GET /api/clients/export-config (+ profileId); optional EXPORT_CONFIG_SECRET - UI: direct link, copy URL, hint when last_config missing Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
11
README.md
11
README.md
@@ -60,6 +60,7 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `CLIENT_CONFIG_ENDPOINT` | _(нет)_ | Публичный IP или DNS VPS для строки `Endpoint` при сборке `.conf` из `last_config`, если нет `hostName` в JSON и заходите в панель не по IP |
|
| `CLIENT_CONFIG_ENDPOINT` | _(нет)_ | Публичный IP или DNS VPS для строки `Endpoint` при сборке `.conf` из `last_config`, если нет `hostName` в JSON и заходите в панель не по IP |
|
||||||
| `CLIENT_EXPORT_DNS1` | `8.8.8.8` | DNS в экспортируемом клиентском конфиге |
|
| `CLIENT_EXPORT_DNS1` | `8.8.8.8` | DNS в экспортируемом клиентском конфиге |
|
||||||
| `CLIENT_EXPORT_DNS2` | `8.8.4.4` | Второй DNS в экспортируемом конфиге |
|
| `CLIENT_EXPORT_DNS2` | `8.8.4.4` | Второй DNS в экспортируемом конфиге |
|
||||||
|
| `EXPORT_CONFIG_SECRET` | _(нет)_ | Секрет для прямой ссылки без входа в панель: `GET /api/clients/export-config?token=СЕКРЕТ&clientId=…&profileId=…` (при нескольких инстансах `profileId` обязателен). Не светите URL посторонним |
|
||||||
| `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` — см. раздел «Пароль» ниже |
|
||||||
@@ -68,6 +69,8 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
|
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
|
||||||
| `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа |
|
| `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа |
|
||||||
|
|
||||||
|
Переменная **`AWG_PROFILES`** при установке автоматически сохраняется в **`/root/amnezia-admin.awg-profiles.json`** на VPS; при следующем запуске `install.sh` без `AWG_PROFILES` значение подставляется из этого файла или из **старого контейнера** `amnezia-admin` перед его удалением — так переключатель «Инстанс» не пропадает после обновления панели.
|
||||||
|
|
||||||
#### Несколько инстансов (AmneziaWG + Legacy и т.д.)
|
#### Несколько инстансов (AmneziaWG + Legacy и т.д.)
|
||||||
|
|
||||||
Пути и имена контейнеров на сервере могут отличаться — проверьте внутри контейнера (`docker exec … ls /opt/amnezia`). Пример **двух** профилей при запуске установщика (одна строка JSON в кавычках):
|
Пути и имена контейнеров на сервере могут отличаться — проверьте внутри контейнера (`docker exec … ls /opt/amnezia`). Пример **двух** профилей при запуске установщика (одна строка JSON в кавычках):
|
||||||
@@ -104,7 +107,13 @@ chmod +x scripts/warp-amnezia.sh
|
|||||||
|
|
||||||
### Экспорт конфигурации клиента (.conf)
|
### Экспорт конфигурации клиента (.conf)
|
||||||
|
|
||||||
Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появится кнопка **«Скачать .conf»**. Такое содержимое сервер получает, когда клиент создавался/синхронизировался через официальное приложение; если на VPS только «голый» `clientsTable` без `last_config`, кнопки не будет — конфиг нужно брать из приложения на устройстве.
|
Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появятся **«Скачать .conf»**, **«Прямая ссылка»** и **«Копировать URL»**. Прямая ссылка имеет вид
|
||||||
|
`/api/clients/export-config?clientId=…&profileId=…` — последний параметр нужен только при нескольких инстансах (`AWG_PROFILES`). Работает в браузере, где вы уже вошли в панель (cookie-сессия).
|
||||||
|
|
||||||
|
Чтобы скачивать **без входа в панель**, задайте **`EXPORT_CONFIG_SECRET`** при установке и открывайте
|
||||||
|
`/api/clients/export-config?token=ВАШ_СЕКРЕТ&clientId=…` — при **нескольких** профилях добавьте **`&profileId=…`**. Не пересылайте такую ссылку третьим лицам.
|
||||||
|
|
||||||
|
Если на VPS только «голый» `clientsTable` без `last_config`, экспорта не будет — конфиг нужно брать из приложения Amnezia на устройстве.
|
||||||
|
|
||||||
Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`).
|
Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`).
|
||||||
|
|
||||||
|
|||||||
@@ -512,6 +512,13 @@ function renderRows(clients) {
|
|||||||
btn("Переименовать", "btn small ghost", () => void renameClient(c))
|
btn("Переименовать", "btn small ghost", () => void renameClient(c))
|
||||||
);
|
);
|
||||||
nameWrap.append(strong, renameWrap);
|
nameWrap.append(strong, renameWrap);
|
||||||
|
if (!c.exportAvailable) {
|
||||||
|
const exHint = document.createElement("p");
|
||||||
|
exHint.className = "muted export-missing-hint";
|
||||||
|
exHint.textContent =
|
||||||
|
"Экспорт .conf с сервера недоступен: нет userData.last_config (ключи только в приложении Amnezia).";
|
||||||
|
nameWrap.appendChild(exHint);
|
||||||
|
}
|
||||||
nameTd.appendChild(nameWrap);
|
nameTd.appendChild(nameWrap);
|
||||||
|
|
||||||
const ipTd = document.createElement("td");
|
const ipTd = document.createElement("td");
|
||||||
@@ -549,8 +556,21 @@ function renderRows(clients) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (c.exportAvailable) {
|
if (c.exportAvailable) {
|
||||||
|
const direct = document.createElement("a");
|
||||||
|
direct.className = "btn small ghost";
|
||||||
|
direct.href = clientExportGetUrl(c.clientId);
|
||||||
|
direct.textContent = "Прямая ссылка";
|
||||||
|
direct.rel = "noopener";
|
||||||
|
direct.title =
|
||||||
|
"Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies).";
|
||||||
|
|
||||||
|
actTd.appendChild(btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c)));
|
||||||
|
actTd.appendChild(direct);
|
||||||
actTd.appendChild(
|
actTd.appendChild(
|
||||||
btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c)),
|
btn("Копировать URL", "btn small ghost", async () => {
|
||||||
|
const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId));
|
||||||
|
setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
||||||
@@ -708,14 +728,46 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Query для нужного инстанса при нескольких профилях AWG_PROFILES */
|
||||||
|
function currentProfileQuerySuffix() {
|
||||||
|
if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return "";
|
||||||
|
const pid = String(protoSelect.value || "").trim();
|
||||||
|
return pid ? `&profileId=${encodeURIComponent(pid)}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прямая GET-ссылка на скачивание (работает в браузере с активной сессией панели). */
|
||||||
|
function clientExportGetUrl(clientId) {
|
||||||
|
const q = `clientId=${encodeURIComponent(clientId)}${currentProfileQuerySuffix()}`;
|
||||||
|
return `${window.location.origin}/api/clients/export-config?${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.left = "-9999px";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
ta.remove();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadClientConfig(c) {
|
async function downloadClientConfig(c) {
|
||||||
try {
|
try {
|
||||||
setStatus("Готовлю конфиг…", false);
|
setStatus("Готовлю конфиг…", false);
|
||||||
const res = await fetch("/api/clients/export-config", {
|
const res = await fetch(clientExportGetUrl(c.clientId), {
|
||||||
method: "POST",
|
method: "GET",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ clientId: c.clientId }),
|
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Панель сервера</p>
|
<p class="eyebrow">Панель сервера</p>
|
||||||
<h1>Пользователи AmneziaWG</h1>
|
<h1>Пользователи AmneziaWG</h1>
|
||||||
<p class="sub">Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов AmneziaWG (Legacy и др.) — в переменной AWG_PROFILES и списке «Инстанс».</p>
|
<p class="sub">Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов задаются переменной <code class="inline">AWG_PROFILES</code>; установщик при апдейте подставляет её из прежнего контейнера или из <code class="inline">/root/amnezia-admin.awg-profiles.json</code>. Конфиг .conf — только если на сервере есть <code class="inline">last_config</code>; иначе используйте приложение Amnezia.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<div class="session-actions">
|
<div class="session-actions">
|
||||||
|
|||||||
@@ -521,6 +521,13 @@ tr:last-child td {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-missing-hint {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
.raw {
|
.raw {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -82,6 +82,33 @@ else
|
|||||||
echo "→ Первый пароль записан в ${PASS_FILE}"
|
echo "→ Первый пароль записан в ${PASS_FILE}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# AWG_PROFILES: не терять при апдейте без переменной (пропадает список «Инстанс»).
|
||||||
|
AWG_PROFILE_SNAPSHOT="/root/amnezia-admin.awg-profiles.json"
|
||||||
|
if [[ -n "${AWG_PROFILES:-}" ]]; then
|
||||||
|
umask 077
|
||||||
|
printf '%s\n' "${AWG_PROFILES}" >"${AWG_PROFILE_SNAPSHOT}" 2>/dev/null || true
|
||||||
|
elif docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
|
||||||
|
PREV_AWG_PROFILES=""
|
||||||
|
while IFS= read -r __env_line; do
|
||||||
|
if [[ "${__env_line}" == AWG_PROFILES=* ]]; then
|
||||||
|
PREV_AWG_PROFILES="${__env_line#AWG_PROFILES=}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < <(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}')
|
||||||
|
if [[ -n "${PREV_AWG_PROFILES}" ]]; then
|
||||||
|
AWG_PROFILES="${PREV_AWG_PROFILES}"
|
||||||
|
echo "→ AWG_PROFILES восстановлен из предыдущего контейнера ${CONTAINER_NAME}."
|
||||||
|
umask 077
|
||||||
|
printf '%s\n' "${AWG_PROFILES}" >"${AWG_PROFILE_SNAPSHOT}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
|
||||||
|
AWG_PROFILES="$(tr -d '\r\n' <"${AWG_PROFILE_SNAPSHOT}" || true)"
|
||||||
|
if [[ -n "${AWG_PROFILES}" ]]; then
|
||||||
|
echo "→ AWG_PROFILES восстановлен из ${AWG_PROFILE_SNAPSHOT}."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
DOCKER_BUILD_EXTRA=()
|
DOCKER_BUILD_EXTRA=()
|
||||||
if [[ "${NO_CACHE:-}" == "1" ]]; then
|
if [[ "${NO_CACHE:-}" == "1" ]]; then
|
||||||
DOCKER_BUILD_EXTRA+=(--no-cache)
|
DOCKER_BUILD_EXTRA+=(--no-cache)
|
||||||
@@ -119,7 +146,7 @@ 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
|
for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 EXPORT_CONFIG_SECRET; do
|
||||||
if [[ -n "${!__export_var:-}" ]]; then
|
if [[ -n "${!__export_var:-}" ]]; then
|
||||||
RUN_ENV+=( -e "${__export_var}=${!__export_var}" )
|
RUN_ENV+=( -e "${__export_var}=${!__export_var}" )
|
||||||
fi
|
fi
|
||||||
|
|||||||
79
server.js
79
server.js
@@ -10,6 +10,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const PORT = Number(process.env.PORT || 3980);
|
const PORT = Number(process.env.PORT || 3980);
|
||||||
const PROFILE_COOKIE = "amnezia_prof";
|
const PROFILE_COOKIE = "amnezia_prof";
|
||||||
const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
|
const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
|
||||||
|
/** Если задан, разрешает GET /api/clients/export-config?token=…&clientId=… без сессии (храните секрет только для себя). */
|
||||||
|
const EXPORT_CONFIG_SECRET = process.env.EXPORT_CONFIG_SECRET?.trim();
|
||||||
|
|
||||||
function parseProfilesFromEnv() {
|
function parseProfilesFromEnv() {
|
||||||
const raw = process.env.AWG_PROFILES?.trim();
|
const raw = process.env.AWG_PROFILES?.trim();
|
||||||
@@ -264,6 +266,38 @@ function requireAuth(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyExportQueryToken(token) {
|
||||||
|
if (!EXPORT_CONFIG_SECRET || typeof token !== "string" || !token) return false;
|
||||||
|
const a = Buffer.from(token, "utf8");
|
||||||
|
const b = Buffer.from(EXPORT_CONFIG_SECRET, "utf8");
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuthOrExportToken(req, res, next) {
|
||||||
|
if (req.method === "GET" && verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireAuth(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeFromExportRequest(req) {
|
||||||
|
const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
|
||||||
|
const bodyPid =
|
||||||
|
req.method === "POST" && typeof req.body?.profileId === "string" ? req.body.profileId.trim() : "";
|
||||||
|
const pid = qPid || bodyPid;
|
||||||
|
if (pid) {
|
||||||
|
const p = PROFILES.find((x) => x.id === pid);
|
||||||
|
if (p) return createRuntime(p);
|
||||||
|
}
|
||||||
|
return runtimeForRequest(req);
|
||||||
|
}
|
||||||
|
|
||||||
function execDocker(args, stdin = null) {
|
function execDocker(args, stdin = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] });
|
const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
@@ -1344,11 +1378,38 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/export-config", requireAuth, async (req, res) => {
|
async function serveClientConfigExport(req, res) {
|
||||||
const rt = runtimeForRequest(req);
|
const tokenOk =
|
||||||
const clientId = req.body?.clientId;
|
req.method === "GET" &&
|
||||||
if (!clientId || typeof clientId !== "string") {
|
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
||||||
res.status(400).json({ error: "Укажите clientId" });
|
|
||||||
|
let rt;
|
||||||
|
if (tokenOk) {
|
||||||
|
if (PROFILES.length > 1) {
|
||||||
|
const pid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
|
||||||
|
const p = PROFILES.find((x) => x.id === pid);
|
||||||
|
if (!p) {
|
||||||
|
res.status(400).json({
|
||||||
|
error:
|
||||||
|
"При нескольких инстансах укажите в URL параметр profileId (как в списке «Инстанс» в панели).",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rt = createRuntime(p);
|
||||||
|
} else {
|
||||||
|
rt = createRuntime(PROFILES[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rt = runtimeFromExportRequest(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawId =
|
||||||
|
req.method === "POST"
|
||||||
|
? req.body?.clientId
|
||||||
|
: req.query.clientId ?? req.query.id;
|
||||||
|
const clientId = typeof rawId === "string" ? decodeURIComponent(rawId.trim()) : "";
|
||||||
|
if (!clientId) {
|
||||||
|
res.status(400).json({ error: "Укажите clientId (в теле POST или query GET)" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1383,6 +1444,14 @@ app.post("/api/clients/export-config", requireAuth, async (req, res) => {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
res.status(500).json({ error: String(e.message || e) });
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/clients/export-config", requireAuthOrExportToken, (req, res) => {
|
||||||
|
void serveClientConfigExport(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/clients/export-config", requireAuth, (req, res) => {
|
||||||
|
void serveClientConfigExport(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user