From c0e19f284d39ab853b0248df7afc97bb75ddc30d Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Tue, 25 Aug 2026 14:57:41 +0300 Subject: [PATCH] Add AWG_GEN/AWG_PIN switches and surface module version Upstream AmneziaWG 3.x is still shifting under active development (unanswered kernel-module issues around header protection and handshake-without-data regressions), so give operators an explicit opt-out/opt-in instead of always auto-selecting 3.1, and pin the installed package so unattended-upgrades can't swap the module under a live server. Surface the running module/tools version and protocol generation through /health, the CLI status command, and the panel's server card so a mismatch is visible instead of silent. --- awg-ctrl/index.ts | 38 ++++++++- awg-ui/src/App.css | 7 ++ awg-ui/src/App.tsx | 5 +- awg-ui/src/components/ServerBar.tsx | 12 ++- awg-ui/src/lib/shared.ts | 4 + awg-ui/src/tabs/users/index.tsx | 10 +-- cli/src/index.ts | 33 +++++++- install.sh | 120 ++++++++++++++++++++-------- 8 files changed, 180 insertions(+), 49 deletions(-) diff --git a/awg-ctrl/index.ts b/awg-ctrl/index.ts index 18ef016..7688570 100644 --- a/awg-ctrl/index.ts +++ b/awg-ctrl/index.ts @@ -196,13 +196,36 @@ function genOf(p: AwgParams): "2" | "3.1" { return p.HeaderProtectionKey ? "3.1" : "2"; } +// Версия модуля/tools для /health — показывает оператору, что реально работает +// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт +// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии +// на диске ровно по той же причине. Вызывается на старте и при restartAwg() — +// это единственные моменты, когда модуль может реально смениться в течение +// жизни процесса; из обработчика /health не вызывается — этот роут без +// авторизации, спавнить подпроцессы на каждый его опрос нельзя. +function readAwgVersions(): { module: string; tools: string } { + let module = ""; + try { + module = readFileSync("/sys/module/amneziawg/version", "utf8").trim(); + } catch { + try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ } + } + let tools = ""; + try { + const out = run("awg --version"); + tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? ""; + } catch { /* awg-tools не найден */ } + return { module, tools }; +} + // PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN); // в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг. const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" }; const runtimeConfig = initConfig(); -const AWG_PARAMS = readAwgParams(); -const AWG_GEN = genOf(AWG_PARAMS); +const AWG_PARAMS = readAwgParams(); +const AWG_GEN = genOf(AWG_PARAMS); +const AWG_VERSIONS = readAwgVersions(); const CONFIG = { interface: "awg1", confDir: "/etc/amnezia/amneziawg", @@ -216,6 +239,10 @@ const CONFIG = { keepalive: KEEPALIVE_BY_GEN[AWG_GEN], awgParams: AWG_PARAMS, gen: AWG_GEN, + // Мутируются в restartAwg() — единственном месте, где модуль может + // реально смениться в течение жизни процесса. + awgModule: AWG_VERSIONS.module, + awgTools: AWG_VERSIONS.tools, }; interface UserRow { @@ -582,7 +609,10 @@ function restartAwg() { const up = spawnSync("awg-quick", ["up", CONFIG.interface]); if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`); syncPeers(); - logger.info("AWG restart: done"); + const versions = readAwgVersions(); + CONFIG.awgModule = versions.module; + CONFIG.awgTools = versions.tools; + logger.info("AWG restart: done", versions); } function startInterface() { @@ -697,7 +727,7 @@ app.get("/health", (_req, res) => { server: CONFIG.serverName, ip: CONFIG.serverIp, gen: CONFIG.gen, - awg: { status: up ? "ok" : "down", peers }, + awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools }, }); }); diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index c6d1a72..7759ddb 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -406,6 +406,13 @@ body { color: var(--neutral-text); line-height: 1.2; } +.server-gen-wrap { align-self: flex-start; margin-top: 2px; } +.server-gen-chip { + align-self: flex-start; + margin-top: 2px; + font-size: 11px; + padding: 2px 8px; +} .server-card-peers { font-size: 12px; color: var(--neutral-text); diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 482786d..2bcb937 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -36,7 +36,10 @@ export default function App() { // вкладок (юзеры, ключи) грузят сами вкладки. const startSession = useCallback(async (tok: string) => { const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); - setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2' }); + setServerInfo({ + name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2', + mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '', + }); setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); }, []); diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx index 85ca81c..7562bb2 100644 --- a/awg-ui/src/components/ServerBar.tsx +++ b/awg-ui/src/components/ServerBar.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. -import { type ServerInfo } from '../lib/shared'; +import { type ServerInfo, genLabel } from '../lib/shared'; import { IcoPlus, IcoRefresh } from './icons'; // Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной) @@ -38,6 +38,16 @@ export default function ServerBar({
{serverInfo.name} {serverInfo.ip} + {(serverInfo.mod || serverInfo.tools) ? ( + + {genLabel(serverInfo.gen)} + + ) : ( + {genLabel(serverInfo.gen)} + )}
{serverInfo.peers} peers diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts index 76da20e..fb133fe 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -21,7 +21,11 @@ export interface ServerInfo { ip: string; peers: number; gen: AwgGen; + mod: string; // версия загруженного модуля amneziawg, из /health awg.module + tools: string; // версия awg-tools, из /health awg.tools } +// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'. +export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0'); export interface ApiKey { id: number; label: string; diff --git a/awg-ui/src/tabs/users/index.tsx b/awg-ui/src/tabs/users/index.tsx index 6abd112..5429cf9 100644 --- a/awg-ui/src/tabs/users/index.tsx +++ b/awg-ui/src/tabs/users/index.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import QRCode from 'qrcode'; import { - apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, + apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel, type User, type PageProps, type AwgGen, } from '../../lib/shared'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; @@ -50,7 +50,7 @@ export default function UsersPage({ token, showMsg }: PageProps) { // IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ. const reissueKeys = useCallback(async () => { if (!confirm( - `Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` + + `Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` + 'Старые ключи перестанут работать — всем придётся импортировать ключ заново.', )) return; try { @@ -162,11 +162,11 @@ export default function UsersPage({ token, showMsg }: PageProps) { {u.key_gen !== serverGen && ( - AWG {u.key_gen} + {genLabel(u.key_gen)} )} @@ -207,7 +207,7 @@ export default function UsersPage({ token, showMsg }: PageProps) { {u.key_gen !== serverGen && ( - AWG {u.key_gen} + {genLabel(u.key_gen)} )} diff --git a/cli/src/index.ts b/cli/src/index.ts index 11602b8..2517528 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -196,7 +196,27 @@ async function restart(name: SvcName) { await start(name); } -function status() { +// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON) +// молча даёт null — health-строка в status() тогда просто не печатается. +interface HealthBody { + gen?: string; + awg?: { module?: string; tools?: string }; +} +function fetchHealth(url: string, timeoutMs = 1000): Promise { + return new Promise(resolve => { + const req = http.get(url, res => { + let body = ""; + res.on("data", (c: Buffer) => { body += c; }); + res.on("end", () => { + try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); } + }); + }); + req.on("error", () => resolve(null)); + req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); }); + }); +} + +async function status() { console.log(""); for (const name of ALL) { const running = isRunning(name); @@ -205,6 +225,13 @@ function status() { const info = running ? green("running") + ` pid ${pid}` : grey("stopped"); console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`); } + if (isRunning("awgctrl")) { + const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`); + if (h?.gen) { + const gen = h.gen === "3.1" ? "3.1" : "2.0"; + console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`); + } + } console.log(""); } @@ -321,7 +348,7 @@ async function interactiveMenu() { case "1": await startAll(); break; case "2": await stopAll(); break; case "3": await restartAll(); break; - case "4": status(); break; + case "4": await status(); break; case "5": await setCredentials("user"); break; case "6": await setCredentials("pass"); break; case "0": rl.close(); process.exit(0); @@ -350,7 +377,7 @@ async function main() { case "start": await startAll(); break; case "stop": await stopAll(); break; case "restart": await restartAll(); break; - case "status": status(); break; + case "status": await status(); break; case "credentials": if (sub === "user") await setCredentials("user"); else if (sub === "pass") await setCredentials("pass"); diff --git a/install.sh b/install.sh index 296e38c..c5c7e4d 100644 --- a/install.sh +++ b/install.sh @@ -134,6 +134,22 @@ H2="2122053282-2133204808" H3="2133604274-2140756116" H4="2143656228-2147444225" +# Поколение протокола и закрепление версии пакета — управляются переменными +# окружения, задаются ДО запуска: `AWG_GEN=2.0 bash install.sh`. +# AWG_GEN=auto (по умолчанию) — как раньше: 3.1, если модуль 3.x и ядро ≥ 5.5, +# иначе 2.0, без лишних вопросов. +# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0. +# AWG_GEN=3.1 — требовать 3.1: если условия не выполняются, install.sh падает +# с диагностикой вместо тихого отката (апстрим ещё нестабилен, см. issues +# #215/#216/#217 в amneziawg-linux-kernel-module — тихий откат на 2.0 здесь +# нежелателен, если оператор явно просил 3.1). +AWG_GEN="${AWG_GEN:-auto}" +[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)" +# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools} +# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым +# сервером (issue #215 — именно так словили регрессию между dkms-сборками). +AWG_PIN="${AWG_PIN:-y}" + NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') [[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" @@ -457,10 +473,11 @@ fi # - ядро: header protection использует библиотечный chacha-API # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. -# Если 3.1 недоступен — не падаем, а пишем 2.0-конфиг: awg-ctrl определяет -# поколение по наличию HeaderProtectionKey в конфиге, так что всё продолжит -# работать ровно как раньше. -AWG3="y" +# Если 3.1 недоступен и AWG_GEN=auto — не падаем, а пишем 2.0-конфиг: awg-ctrl +# определяет поколение по наличию HeaderProtectionKey в конфиге, так что всё +# продолжит работать ровно как раньше. При AWG_GEN=3.1 недоступность — fail +# (см. AWG_GEN выше): апстрим модуля ещё нестабилен, тихий откат при явном +# запросе 3.1 может незаметно подсунуть более слабую обфускацию. kernel_lt_5_5() { local maj min @@ -479,42 +496,59 @@ kernel_lt_5_5() { disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; } loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; } -MOD_VER=$(disk_mod_ver) -if [[ "${MOD_VER%%.*}" != "3" ]]; then - warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить." - apt-get update >/dev/null 2>&1 || true - DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ - apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true +if [[ "$AWG_GEN" == "2.0" ]]; then + AWG3="n" MOD_VER=$(disk_mod_ver) -fi + ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0." +else + AWG3="y" -# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль, -# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен. -LOADED_VER=$(loaded_mod_ver) -if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then - warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль." - awg-quick down "$IFACE" 2>/dev/null || true - ip link delete dev "$IFACE" 2>/dev/null || true - if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then - LOADED_VER=$(loaded_mod_ver) - ok "Модуль перезагружен: в ядре теперь $LOADED_VER" - else - warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера." + MOD_VER=$(disk_mod_ver) + if [[ "${MOD_VER%%.*}" != "3" ]]; then + warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить." + # Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) — + # снимаем, иначе --only-upgrade молча не тронет held-версию. + apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true + apt-get update >/dev/null 2>&1 || true + DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ + apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true + MOD_VER=$(disk_mod_ver) fi -fi -# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс. -[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER" + # Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль, + # поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен. + LOADED_VER=$(loaded_mod_ver) + if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then + warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль." + awg-quick down "$IFACE" 2>/dev/null || true + ip link delete dev "$IFACE" 2>/dev/null || true + if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then + LOADED_VER=$(loaded_mod_ver) + ok "Модуль перезагружен: в ядре теперь $LOADED_VER" + else + warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера." + fi + fi -if [[ "${MOD_VER%%.*}" != "3" ]]; then - AWG3="n" - warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." - [[ "$(disk_mod_ver)" == 3.* ]] && \ - warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1." -elif kernel_lt_5_5; then - AWG3="n" - warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." - warn "Ставим сервер на AmneziaWG 2.0." + # Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс. + [[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER" + + if [[ "${MOD_VER%%.*}" != "3" ]]; then + AWG3="n" + if [[ "$AWG_GEN" == "3.1" ]]; then + fail "AWG_GEN=3.1 запрошен явно, но в ядре модуль версии '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)) — нужна 3.x." + fi + warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." + [[ "$(disk_mod_ver)" == 3.* ]] && \ + warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1." + elif kernel_lt_5_5; then + AWG3="n" + if [[ "$AWG_GEN" == "3.1" ]]; then + fail "AWG_GEN=3.1 запрошен явно, но ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." + fi + warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." + warn "Ставим сервер на AmneziaWG 2.0." + fi fi if [[ "$AWG3" == "y" ]]; then @@ -696,6 +730,8 @@ if ! awg-quick up "$IFACE"; then sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF" AWG3="n"; NEW_GEN="2.0" warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31" + [[ "$AWG_GEN" == "3.1" ]] && \ + warn "Запрошено AWG_GEN=3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше." awg-quick up "$IFACE" \ || fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше" @@ -719,6 +755,20 @@ else fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY" fi +TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}') + +# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под +# живым сервером (issue #215: между двумя dkms-сборками словили регрессию — +# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше; +# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся. +if [[ "$AWG_PIN" == "y" ]]; then + apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true + ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools" +fi + +GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0") +ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")" + # UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец # цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT # дело не доходит и трафик клиентов наружу режется: в dmesg сыплется