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_EXPORT_DNS1` | `8.8.8.8` | DNS в экспортируемом клиентском конфиге |
|
||||
| `CLIENT_EXPORT_DNS2` | `8.8.4.4` | Второй DNS в экспортируемом конфиге |
|
||||
| `EXPORT_CONFIG_SECRET` | _(нет)_ | Секрет для прямой ссылки без входа в панель: `GET /api/clients/export-config?token=СЕКРЕТ&clientId=…&profileId=…` (при нескольких инстансах `profileId` обязателен). Не светите URL посторонним |
|
||||
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
||||
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
||||
| `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` | Имя контейнера лендинга |
|
||||
| `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 и т.д.)
|
||||
|
||||
Пути и имена контейнеров на сервере могут отличаться — проверьте внутри контейнера (`docker exec … ls /opt/amnezia`). Пример **двух** профилей при запуске установщика (одна строка JSON в кавычках):
|
||||
@@ -104,7 +107,13 @@ chmod +x scripts/warp-amnezia.sh
|
||||
|
||||
### Экспорт конфигурации клиента (.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`).
|
||||
|
||||
|
||||
@@ -512,6 +512,13 @@ function renderRows(clients) {
|
||||
btn("Переименовать", "btn small ghost", () => void renameClient(c))
|
||||
);
|
||||
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);
|
||||
|
||||
const ipTd = document.createElement("td");
|
||||
@@ -549,8 +556,21 @@ function renderRows(clients) {
|
||||
);
|
||||
}
|
||||
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(
|
||||
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)));
|
||||
@@ -708,14 +728,46 @@ function escapeHtml(s) {
|
||||
.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) {
|
||||
try {
|
||||
setStatus("Готовлю конфиг…", false);
|
||||
const res = await fetch("/api/clients/export-config", {
|
||||
method: "POST",
|
||||
const res = await fetch(clientExportGetUrl(c.clientId), {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientId: c.clientId }),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Панель сервера</p>
|
||||
<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 class="token-box">
|
||||
<div class="session-actions">
|
||||
|
||||
@@ -521,6 +521,13 @@ tr:last-child td {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-missing-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -82,6 +82,33 @@ else
|
||||
echo "→ Первый пароль записан в ${PASS_FILE}"
|
||||
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=()
|
||||
if [[ "${NO_CACHE:-}" == "1" ]]; then
|
||||
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
|
||||
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
|
||||
RUN_ENV+=( -e "${__export_var}=${!__export_var}" )
|
||||
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 PROFILE_COOKIE = "amnezia_prof";
|
||||
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() {
|
||||
const raw = process.env.AWG_PROFILES?.trim();
|
||||
@@ -264,6 +266,38 @@ function requireAuth(req, res, 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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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) => {
|
||||
const rt = runtimeForRequest(req);
|
||||
const clientId = req.body?.clientId;
|
||||
if (!clientId || typeof clientId !== "string") {
|
||||
res.status(400).json({ error: "Укажите clientId" });
|
||||
async function serveClientConfigExport(req, res) {
|
||||
const tokenOk =
|
||||
req.method === "GET" &&
|
||||
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
||||
|
||||
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;
|
||||
}
|
||||
try {
|
||||
@@ -1383,6 +1444,14 @@ app.post("/api/clients/export-config", requireAuth, async (req, res) => {
|
||||
console.error(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) => {
|
||||
|
||||
Reference in New Issue
Block a user