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:
Андрей Бобырев
2026-05-14 18:32:51 +03:00
parent 75a3e56354
commit 227bb94627
6 changed files with 177 additions and 13 deletions

View File

@@ -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`).

View File

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

View File

@@ -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">

View File

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

View File

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

View File

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