4 Commits
Author SHA1 Message Date
maeneko 6fdacd71c3 Fix 3.1 rollout on servers with an older loaded module and UFW
Three defects surfaced on a real upgrade from 2.0.

The module gate read modinfo, which reports the version on disk. apt
--only-upgrade rebuilds the DKMS module but does not replace the one already
loaded, so the installer wrote a 3.1 conf that the running 2.0 module rejected
with 'Unable to modify interface: Invalid argument'. It now compares
/sys/module/amneziawg/version, reloads the module when it lags behind the disk,
and decides the generation from the loaded version only.

Bringing the interface up is the only real proof the kernel accepted the 3.1
parameters, so a failure there no longer aborts the install: it enables dynamic
debug, retries, prints the amneziawg lines from dmesg, then strips the 3.x keys
and comes up on 2.0. The rejected conf is kept as awg1.conf.awg31.

PostUp appends 'iptables -A FORWARD', but UFW inserts its jumps at the head of
the chain, so client traffic was dropped with '[UFW BLOCK] IN=awg1 OUT=eth0' —
the tunnel connected and had no internet. When UFW is active the installer now
adds 'ufw route allow in on awg1 out on eth0' and opens the AWG and UI ports.

Version 0.1.4.1.
2026-08-17 18:48:38 +03:00
maeneko af5c0e1b43 Publish releases as prereleases
The project is still Alpha, so a v* tag should not become the repo's latest
stable release.
2026-08-17 18:06:59 +03:00
maeneko 01d8524f8c Bump version to 0.1.4
install.sh drives the release URL and tarball name off VERSION, so the
matching tag is v0.1.4. awg-ctrl/package.json was still on the default 1.0.0
and is now in step with cli and awg-ui.
2026-08-17 18:01:35 +03:00
maeneko 44c637f7af Add AmneziaWG 3.1 support with key reissue
Protocol generation is now derived from awg1.conf rather than stored: an
interface speaks 3.1 when HeaderProtectionKey is set and 2.0 otherwise. The
3.0/3.1-only keys (HeaderProtectionKey, ContentPaddingAddition, the
Rekey/Reject/Keepalive timers, MaxHandshakeAttempts, RandomTrailers,
DisableCookies) are read from the conf and emitted only when non-empty, so 2.0
output stays byte-identical — verified by diffing both the client .conf and the
full vpn:// string against the previous implementation.

Changing generation invalidates every issued vpn:// key, so add
POST /api/users/reissue. It recovers the client private key from inside the
stored blob (the only place it exists), keeping ip/pub_key/psk_key intact, so
nothing changes on the wire and users only need to re-import. users gains
key_gen and vpn_key_prev, and users.db is snapshotted before the pass.

install.sh gates 3.1 on module 3.x and kernel >= 5.5 (header protection needs
the chacha library API, absent before 5.5) and falls back to writing a 2.0 conf
instead of aborting, so older kernels keep working as before.
HeaderProtectionKey is generated with awg genpsk and preserved on KEEP_DATA.

The panel marks keys issued on a different generation and offers to reissue
them; /api/v1 now returns gen alongside name/ip/vpn_key.

Also add MIT license headers across the awg-ui sources.
2026-08-17 17:58:00 +03:00
21 changed files with 502 additions and 51 deletions
+1 -1
View File
@@ -37,4 +37,4 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \ gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
--title "${GITHUB_REF_NAME}" --generate-notes --title "${GITHUB_REF_NAME}" --generate-notes --prerelease
+216 -33
View File
@@ -64,6 +64,21 @@ db.exec(`
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)"); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)");
// Миграции users: better-sqlite3 синхронный, ALTER TABLE идемпотентным не бывает,
// поэтому смотрим фактический список колонок.
// key_gen — поколение протокола, на параметрах которого выдан vpn_key
// ('2' для всего, что заведено до появления 3.1)
// vpn_key_prev — предыдущий блоб, чтобы перевыпуск можно было откатить
{
const cols = new Set(
(db.prepare("PRAGMA table_info(users)").all() as { name: string }[]).map(c => c.name),
);
if (!cols.has("key_gen"))
db.exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'");
if (!cols.has("vpn_key_prev"))
db.exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''");
}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS config ( CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -106,12 +121,29 @@ function initConfig() {
return { serverIp, serverPort: Number(serverPort), serverName }; return { serverIp, serverPort: Number(serverPort), serverName };
} }
interface AwgParams { // Ключи, появившиеся в AmneziaWG 3.0/3.1. Пустая строка = ключ не задан, тогда
// интерфейс работает в режиме 2.0 и его нет ни в conf, ни в vpn:// ключе.
// Порядок массива = порядок строк в клиентском .conf (см. buildClientConf).
const AWG3_KEYS = [
"HeaderProtectionKey",
"ContentPaddingAddition",
"RekeyAfterTime",
"RekeyTimeout",
"RejectAfterTime",
"KeepaliveTimeout",
"MaxHandshakeAttempts",
"RandomTrailers",
"DisableCookies",
] as const;
type Awg3Key = (typeof AWG3_KEYS)[number];
type AwgParams = {
Jc: number; Jmin: number; Jmax: number; Jc: number; Jmin: number; Jmax: number;
S1: number; S2: number; S3: number; S4: number; S1: number; S2: number; S3: number; S4: number;
H1: string; H2: string; H3: string; H4: string; H1: string; H2: string; H3: string; H4: string;
I1: string; I2: string; I3: string; I4: string; I5: string; I1: string; I2: string; I3: string; I4: string; I5: string;
} } & Record<Awg3Key, string>;
const DEFAULT_AWG_PARAMS: AwgParams = { const DEFAULT_AWG_PARAMS: AwgParams = {
Jc: 6, Jmin: 10, Jmax: 50, Jc: 6, Jmin: 10, Jmax: 50,
@@ -122,6 +154,11 @@ const DEFAULT_AWG_PARAMS: AwgParams = {
H4: "2143656228-2147444225", H4: "2143656228-2147444225",
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>", I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
I2: "", I3: "", I4: "", I5: "", I2: "", I3: "", I4: "", I5: "",
// 3.x по умолчанию выключен: без conf-а сервер остаётся на 2.0.
HeaderProtectionKey: "", ContentPaddingAddition: "",
RekeyAfterTime: "", RekeyTimeout: "", RejectAfterTime: "",
KeepaliveTimeout: "", MaxHandshakeAttempts: "",
RandomTrailers: "", DisableCookies: "",
}; };
function readAwgParams(): AwgParams { function readAwgParams(): AwgParams {
@@ -134,7 +171,9 @@ function readAwgParams(): AwgParams {
const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0]; const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0];
const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"]; const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"];
const strKeys: (keyof AwgParams)[] = ["H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]; const strKeys: (keyof AwgParams)[] = [
"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5", ...AWG3_KEYS,
];
for (const k of numKeys) { for (const k of numKeys) {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m")); const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m"));
@@ -144,11 +183,26 @@ function readAwgParams(): AwgParams {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m")); const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"));
if (m) (params[k] as string) = m[1].trim(); if (m) (params[k] as string) = m[1].trim();
} }
logger.info("awg params loaded from conf", { Jc: params.Jc, H1: params.H1 }); logger.info("awg params loaded from conf", {
Jc: params.Jc, H1: params.H1, gen: genOf(params),
});
return params; return params;
} }
// Поколение протокола выводим из самих параметров, отдельного флага нет: conf
// остаётся единственным источником правды. Header protection — та фича, которая
// ломает совместимость с 2.0, поэтому именно она и определяет поколение.
function genOf(p: AwgParams): "2" | "3.1" {
return p.HeaderProtectionKey ? "3.1" : "2";
}
// 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 runtimeConfig = initConfig();
const AWG_PARAMS = readAwgParams();
const AWG_GEN = genOf(AWG_PARAMS);
const CONFIG = { const CONFIG = {
interface: "awg1", interface: "awg1",
confDir: "/etc/amnezia/amneziawg", confDir: "/etc/amnezia/amneziawg",
@@ -159,23 +213,32 @@ const CONFIG = {
dns1: "1.1.1.1", dns1: "1.1.1.1",
dns2: "1.0.0.1", dns2: "1.0.0.1",
mtu: 1376, mtu: 1376,
keepalive: 25, keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
awgParams: readAwgParams(), awgParams: AWG_PARAMS,
gen: AWG_GEN,
}; };
interface UserRow { interface UserRow {
name: string; name: string;
ip: string; ip: string;
pub_key: string; pub_key: string;
vpn_key: string; vpn_key: string;
psk_key: string; psk_key: string;
key_gen: string;
vpn_key_prev: string;
} }
const stmts = { const stmts = {
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"), get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
insert: db.prepare<[string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key) VALUES (?, ?, ?, ?, ?)"), all: db.prepare<[], UserRow>("SELECT * FROM users"),
insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"),
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"), delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"), ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"),
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
// смениться, если исходный ключ не удалось разобрать.
reissue: db.prepare<[string, string, string, string, string]>(
"UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
),
}; };
function run(cmd: string): string { function run(cmd: string): string {
@@ -207,7 +270,10 @@ function nextIp(): string {
throw new Error("Подсеть заполнена"); throw new Error("Подсеть заполнена");
} }
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → Address → DNS // Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → 3.x-ключи →
// Address → DNS. Порядок 3.x-блока взят из client/server_scripts/awg/template.conf
// клиента AmneziaVPN; пустые ключи не выводятся вовсе — тогда конфиг остаётся
// ровно тем же 2.0-конфигом, что и до появления поддержки 3.1.
// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом // ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали // (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»! // ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
@@ -217,6 +283,7 @@ function buildClientConf(
serverPub: string, serverPub: string,
): string { ): string {
const p = CONFIG.awgParams; const p = CONFIG.awgParams;
const awg3 = AWG3_KEYS.filter(k => p[k]).map(k => `${k} = ${p[k]}\n`).join("");
return `[Interface] return `[Interface]
PrivateKey = ${keys.privateKey} PrivateKey = ${keys.privateKey}
Jc = ${p.Jc} Jc = ${p.Jc}
@@ -235,7 +302,7 @@ I2 =${" "}
I3 =${" "} I3 =${" "}
I4 =${" "} I4 =${" "}
I5 =${" "} I5 =${" "}
Address = ${ip}/32 ${awg3}Address = ${ip}/32
DNS = ${CONFIG.dns1}, ${CONFIG.dns2} DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
[Peer] [Peer]
@@ -255,12 +322,29 @@ function encodeVpnKey(
const p = CONFIG.awgParams; const p = CONFIG.awgParams;
const clientConf = buildClientConf(keys, ip, serverPub); const clientConf = buildClientConf(keys, ip, serverPub);
const lastConfigObj = { // Ключи в объектах идут в том же ASCII-алфавитном порядке, в каком их
// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи
// выбрасываются в dropEmptyAwg3 — на 2.0 объекты остаются прежними байт-в-байт.
const dropEmptyAwg3 = <T extends Record<string, unknown>>(o: T): T => {
for (const k of AWG3_KEYS) if (!p[k]) delete o[k];
return o;
};
const lastConfigObj = dropEmptyAwg3({
ContentPaddingAddition: p.ContentPaddingAddition,
DisableCookies: p.DisableCookies,
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4, H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
HeaderProtectionKey: p.HeaderProtectionKey,
I1: p.I1, I2: "", I3: "", I4: "", I5: "", I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc), Jc: String(p.Jc),
Jmax: String(p.Jmax), Jmax: String(p.Jmax),
Jmin: String(p.Jmin), Jmin: String(p.Jmin),
KeepaliveTimeout: p.KeepaliveTimeout,
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
RandomTrailers: p.RandomTrailers,
RejectAfterTime: p.RejectAfterTime,
RekeyAfterTime: p.RekeyAfterTime,
RekeyTimeout: p.RekeyTimeout,
S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4), S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4),
allowed_ips: ["0.0.0.0/0", "::/0"], allowed_ips: ["0.0.0.0/0", "::/0"],
clientId: keys.publicKey, clientId: keys.publicKey,
@@ -274,25 +358,34 @@ function encodeVpnKey(
port: CONFIG.serverPort, port: CONFIG.serverPort,
psk_key: keys.presharedKey, psk_key: keys.presharedKey,
server_pub_key: serverPub, server_pub_key: serverPub,
}; });
const json = JSON.stringify({ const json = JSON.stringify({
containers: [{ containers: [{
container: "amnezia-awg2", container: "amnezia-awg2",
awg: { awg: dropEmptyAwg3({
ContentPaddingAddition: p.ContentPaddingAddition,
DisableCookies: p.DisableCookies,
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4, H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
HeaderProtectionKey: p.HeaderProtectionKey,
I1: p.I1, I2: "", I3: "", I4: "", I5: "", I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc), Jc: String(p.Jc),
Jmax: String(p.Jmax), Jmax: String(p.Jmax),
Jmin: String(p.Jmin), Jmin: String(p.Jmin),
KeepaliveTimeout: p.KeepaliveTimeout,
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
RandomTrailers: p.RandomTrailers,
RejectAfterTime: p.RejectAfterTime,
RekeyAfterTime: p.RekeyAfterTime,
RekeyTimeout: p.RekeyTimeout,
S1: String(p.S1), S2: String(p.S2), S1: String(p.S1), S2: String(p.S2),
S3: String(p.S3), S4: String(p.S4), S3: String(p.S3), S4: String(p.S4),
last_config: JSON.stringify(lastConfigObj, null, 2), last_config: JSON.stringify(lastConfigObj, null, 2),
port: String(CONFIG.serverPort), port: String(CONFIG.serverPort),
protocol_version: "2", protocol_version: CONFIG.gen,
subnet_address: `${CONFIG.subnet}.0.0`, subnet_address: `${CONFIG.subnet}.0.0`,
transport_proto: "udp", transport_proto: "udp",
}, }),
}], }],
defaultContainer: "amnezia-awg2", defaultContainer: "amnezia-awg2",
description: CONFIG.serverName, description: CONFIG.serverName,
@@ -311,6 +404,77 @@ function encodeVpnKey(
.replace(/=+$/, ""); .replace(/=+$/, "");
} }
// Обратная к encodeVpnKey: vpn:// → base64url → снять 4-байтовый BE-заголовок
// длины → inflate → JSON. Приватный ключ клиента больше нигде не хранится, поэтому
// это единственный способ перевыпустить ключ, не меняя личность пира.
function decodeVpnKey(vpnKey: string): any | null {
try {
const buf = Buffer.from(vpnKey.replace(/^vpn:\/\//, ""), "base64url");
if (buf.length <= 4) return null;
return JSON.parse(zlib.inflateSync(buf.subarray(4)).toString("utf8"));
} catch {
return null;
}
}
function clientPrivKeyFrom(vpnKey: string): string | null {
const lastConfig = decodeVpnKey(vpnKey)?.containers?.[0]?.awg?.last_config;
if (typeof lastConfig !== "string") return null;
try {
const priv = JSON.parse(lastConfig).client_priv_key;
return typeof priv === "string" && priv ? priv : null;
} catch {
return null;
}
}
interface ReissueResult { total: number; reissued: number; regenerated: string[]; backup: string }
// Перевыпуск всех vpn:// ключей на текущих параметрах интерфейса. Нужен после
// смены поколения (2.0 → 3.1): старые ключи собраны на старых параметрах и
// перестают работать. IP, pub_key и psk_key сохраняются — на проводе ничего не
// меняется, клиенту достаточно заново импортировать ключ.
function reissueAll(): ReissueResult {
const users = stmts.all.all();
const serverPub = getServerPublicKey();
const backup = `${dbPath}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
db.prepare("VACUUM INTO ?").run(backup);
logger.info("reissue: db backed up", { backup, users: users.length });
const regenerated: string[] = [];
let reissued = 0;
for (const u of users) {
const priv = clientPrivKeyFrom(u.vpn_key);
let keys: ReturnType<typeof generateKeys>;
if (priv) {
keys = { privateKey: priv, publicKey: u.pub_key, presharedKey: u.psk_key };
} else {
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
logger.warn("reissue: vpn_key не декодируется, генерируем новую пару", { name: u.name });
keys = generateKeys();
spawnSync("awg", ["set", CONFIG.interface, "peer", u.pub_key, "remove"]);
const r = setPeer(keys.publicKey, keys.presharedKey, u.ip);
if (r.status !== 0) {
logger.error("reissue: awg set failed", { name: u.name, stderr: r.stderr?.toString() });
continue;
}
regenerated.push(u.name);
}
stmts.reissue.run(
encodeVpnKey(keys, u.ip, serverPub),
keys.publicKey, keys.presharedKey, CONFIG.gen, u.name,
);
reissued++;
}
syncPeers();
logger.info("reissue: done", { total: users.length, reissued, regenerated: regenerated.length });
return { total: users.length, reissued, regenerated, backup };
}
function getPeersData(): Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> { function getPeersData(): Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> {
try { try {
const output = run(`awg show ${CONFIG.interface} dump`); const output = run(`awg show ${CONFIG.interface} dump`);
@@ -430,36 +594,47 @@ function startInterface() {
return getInterfaceStatus(); return getInterfaceStatus();
} }
// PSK уходит во временный файл, а не в аргументы: иначе он виден в /proc/<pid>/cmdline.
function setPeer(pubKey: string, psk: string, ip: string) {
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
writeFileSync(tmpPsk, psk, { mode: 0o600 });
try {
return spawnSync("awg", [
"set", CONFIG.interface, "peer", pubKey,
"preshared-key", tmpPsk,
"allowed-ips", `${ip}/32`,
"persistent-keepalive", CONFIG.keepalive,
]);
} finally {
try { fs.unlinkSync(tmpPsk); } catch {}
}
}
function addUser(username: string): UserRow { function addUser(username: string): UserRow {
const keys = generateKeys(); const keys = generateKeys();
const serverPub = getServerPublicKey(); const serverPub = getServerPublicKey();
const ip = db.transaction(() => { const ip = db.transaction(() => {
const ip = nextIp(); const ip = nextIp();
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey); stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey, CONFIG.gen);
return ip; return ip;
})(); })();
const vpn_key = encodeVpnKey(keys, ip, serverPub); const vpn_key = encodeVpnKey(keys, ip, serverPub);
db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username); db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username);
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`; const r = setPeer(keys.publicKey, keys.presharedKey, ip);
writeFileSync(tmpPsk, keys.presharedKey);
const r = spawnSync("awg", [
"set", CONFIG.interface, "peer", keys.publicKey,
"preshared-key", tmpPsk,
"allowed-ips", `${ip}/32`,
"persistent-keepalive", String(CONFIG.keepalive),
]);
try { fs.unlinkSync(tmpPsk); } catch {}
if (r.status !== 0) { if (r.status !== 0) {
stmts.delete.run(username); stmts.delete.run(username);
throw new Error(`awg set failed: ${r.stderr?.toString()}`); throw new Error(`awg set failed: ${r.stderr?.toString()}`);
} }
rebuildConf(); rebuildConf();
logger.info("user created", { name: username, ip }); logger.info("user created", { name: username, ip, gen: CONFIG.gen });
return { name: username, ip, pub_key: keys.publicKey, vpn_key, psk_key: keys.presharedKey }; return {
name: username, ip, pub_key: keys.publicKey, vpn_key,
psk_key: keys.presharedKey, key_gen: CONFIG.gen, vpn_key_prev: "",
};
} }
function removeUser(username: string) { function removeUser(username: string) {
@@ -521,6 +696,7 @@ app.get("/health", (_req, res) => {
status: up ? "ok" : "degraded", status: up ? "ok" : "degraded",
server: CONFIG.serverName, server: CONFIG.serverName,
ip: CONFIG.serverIp, ip: CONFIG.serverIp,
gen: CONFIG.gen,
awg: { status: up ? "ok" : "down", peers }, awg: { status: up ? "ok" : "down", peers },
}); });
}); });
@@ -534,7 +710,7 @@ app.post("/api/users", auth, validateName, handler((req, res) => {
})); }));
app.get("/api/users", auth, handler((_req, res) => { app.get("/api/users", auth, handler((_req, res) => {
const users = db.prepare("SELECT name, ip, pub_key, vpn_key FROM users WHERE vpn_key != ''").all() as UserRow[]; const users = db.prepare("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData(); const peers = getPeersData();
res.json({ res.json({
users: users.map(u => ({ users: users.map(u => ({
@@ -546,12 +722,13 @@ app.get("/api/users", auth, handler((_req, res) => {
})); }));
app.get("/api/users/stats", auth, handler((_req, res) => { app.get("/api/users/stats", auth, handler((_req, res) => {
const users = db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]; const users = db.prepare("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData(); const peers = getPeersData();
res.json({ res.json({
users: users.map(u => ({ users: users.map(u => ({
name: u.name, name: u.name,
ip: u.ip, ip: u.ip,
key_gen: u.key_gen,
online: peers[u.pub_key]?.online ?? false, online: peers[u.pub_key]?.online ?? false,
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0, lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
rx: peers[u.pub_key]?.rx ?? 0, rx: peers[u.pub_key]?.rx ?? 0,
@@ -560,6 +737,12 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
}); });
})); }));
// ⚠️ Должен быть объявлен ДО «/api/users/:name», иначе тот перехватит «reissue»
// как имя пользователя.
app.post("/api/users/reissue", auth, handler((_req, res) => {
res.json(reissueAll());
}));
app.post("/api/users/:name", auth, validateName, handler((req, res) => { app.post("/api/users/:name", auth, validateName, handler((req, res) => {
const user = stmts.get.get(req.params.name); const user = stmts.get.get(req.params.name);
if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; } if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "1.0.0", "version": "0.1.4+1",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.1.3+1", "version": "0.1.4+1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8 -4
View File
@@ -210,6 +210,10 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
}); });
} }
// Внешний контракт: наружу отдаём только name/ip/gen/vpn_key — psk_key и pub_key
// остаются внутри. gen — поколение AmneziaWG, на параметрах которого выдан ключ.
interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router(); const ext = express.Router();
ext.use(requireApiKey); ext.use(requireApiKey);
@@ -217,8 +221,8 @@ ext.post("/users", async (req: Request, res: Response) => {
const { name } = (req.body ?? {}) as { name?: string }; const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name }); const r = await ctrl("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as { name: string; ip: string; vpn_key: string }; const u = r.data as ExtUser;
res.status(201).json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key }); res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}); });
ext.get("/users", async (_req: Request, res: Response) => { ext.get("/users", async (_req: Request, res: Response) => {
@@ -229,8 +233,8 @@ ext.get("/users", async (_req: Request, res: Response) => {
ext.get("/users/:name", async (req: Request, res: Response) => { ext.get("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`); const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as { name: string; ip: string; vpn_key: string }; const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key }); res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}); });
ext.delete("/users/:name", async (req: Request, res: Response) => { ext.delete("/users/:name", async (req: Request, res: Response) => {
+2
View File
@@ -352,6 +352,8 @@ body {
.chip--online .chip-dot { background: var(--success-dot); } .chip--online .chip-dot { background: var(--success-dot); }
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); } .chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
.chip--offline .chip-dot { background: var(--neutral-dot); } .chip--offline .chip-dot { background: var(--neutral-dot); }
.chip--error { background: var(--error-bg); color: var(--error-text); }
.chip--error .chip-dot { background: var(--error-dot); }
.main { .main {
flex: 1; flex: 1;
+2 -2
View File
@@ -36,7 +36,7 @@ export default function App() {
// вкладок (юзеры, ключи) грузят сами вкладки. // вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => { const startSession = useCallback(async (tok: string) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 }); setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2' });
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
}, []); }, []);
@@ -214,7 +214,7 @@ export default function App() {
> >
<div className="sidebar-logo"> <div className="sidebar-logo">
<span className="logo-name">Forgetting</span> <span className="logo-name">Forgetting</span>
<span className="sidebar-version">Alpha 0.1.3.1</span> <span className="sidebar-version">Alpha 0.1.4.1</span>
</div> </div>
<hr className="drawer-divider" /> <hr className="drawer-divider" />
{TABS.map(t => ( {TABS.map(t => (
+3 -1
View File
@@ -1,4 +1,6 @@
// 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 } from '../lib/shared';
import { IcoPlus, IcoRefresh } from './icons'; import { IcoPlus, IcoRefresh } from './icons';
+3
View File
@@ -1,3 +1,6 @@
// 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.
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда // Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json). // её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
+7
View File
@@ -1,10 +1,16 @@
// 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 axios from 'axios'; import axios from 'axios';
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
export type AwgGen = '2' | '3.1';
export interface User { export interface User {
name: string; name: string;
ip: string; ip: string;
pub_key: string; pub_key: string;
vpn_key: string; vpn_key: string;
key_gen: AwgGen;
online: boolean; online: boolean;
lastHandshake: number; lastHandshake: number;
rx?: number; rx?: number;
@@ -14,6 +20,7 @@ export interface ServerInfo {
name: string; name: string;
ip: string; ip: string;
peers: number; peers: number;
gen: AwgGen;
} }
export interface ApiKey { export interface ApiKey {
id: number; id: number;
+3
View File
@@ -1,3 +1,6 @@
// 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 { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
+5
View File
@@ -1,3 +1,8 @@
/*
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.
*/
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */ /* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */ /* Открытый API-ключ (показывается один раз при создании) */
+3
View File
@@ -1,3 +1,6 @@
// 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 { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared'; import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
+3
View File
@@ -1,3 +1,6 @@
// 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.
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/: // Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика // index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент) // <name>.css — стили вкладки (импортит сам компонент)
+54 -3
View File
@@ -1,8 +1,11 @@
// 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 { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
type User, type PageProps, type User, type PageProps, type AwgGen,
} from '../../lib/shared'; } from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import './users.css'; import './users.css';
@@ -13,7 +16,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null); const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
// поколении, уже не подключатся — их надо перевыпустить.
const [serverGen, setServerGen] = useState<AwgGen>('2');
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null); const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stale = users.filter(u => u.key_gen !== serverGen);
const loadStats = useCallback(async (tok: string) => { const loadStats = useCallback(async (tok: string) => {
try { try {
@@ -27,14 +34,34 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const loadUsers = useCallback(async (tok: string) => { const loadUsers = useCallback(async (tok: string) => {
try { try {
const data = await apiFetch('GET', '/api/users', tok); const [data, health] = await Promise.all([
apiFetch('GET', '/api/users', tok),
apiFetch('GET', '/health', tok),
]);
setUsers(data.users ?? []); setUsers(data.users ?? []);
setServerGen(health.gen ?? '2');
await loadStats(tok); await loadStats(tok);
} catch { } catch {
showMsg('Ошибка загрузки'); showMsg('Ошибка загрузки');
} }
}, [loadStats, showMsg]); }, [loadStats, showMsg]);
// Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса.
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
const reissueKeys = useCallback(async () => {
if (!confirm(
`Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` +
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
)) return;
try {
const r = await apiFetch('POST', '/api/users/reissue', token);
showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`);
await loadUsers(token);
} catch {
showMsg('Не удалось перевыпустить ключи');
}
}, [stale.length, serverGen, token, loadUsers, showMsg]);
const createUser = useCallback(async () => { const createUser = useCallback(async () => {
if (!newName) return; if (!newName) return;
const u = await apiFetch('POST', '/api/users', token, { name: newName }); const u = await apiFetch('POST', '/api/users', token, { name: newName });
@@ -93,6 +120,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<button className="btn btn--tonal" onClick={() => loadUsers(token)}> <button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить <IcoRefresh /> Обновить
</button> </button>
{stale.length > 0 && (
<button className="btn btn--danger" onClick={reissueKeys}>
<IcoRefresh /> Перевыпустить ключи ({stale.length})
</button>
)}
</div> </div>
</div> </div>
@@ -125,7 +157,20 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'} : 'никогда'}
</span> </span>
</td> </td>
<td>{u.name}</td> <td>
{u.name}
{u.key_gen !== serverGen && (
<span
className="tip-wrap user-gen"
data-tip={`Ключ выдан на AWG ${u.key_gen}, сервер работает на ${serverGen}`}
>
<span className="chip chip--error">
<span className="chip-dot" />
AWG {u.key_gen}
</span>
</span>
)}
</td>
<td className="td-mono">{u.ip}</td> <td className="td-mono">{u.ip}</td>
<td>{bytes(u.rx)}</td> <td>{bytes(u.rx)}</td>
<td>{bytes(u.tx)}</td> <td>{bytes(u.tx)}</td>
@@ -159,6 +204,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-title"> <div className="user-card-title">
<span className="user-card-num">#{i + 1}</span> <span className="user-card-num">#{i + 1}</span>
<span className="user-card-name">{u.name}</span> <span className="user-card-name">{u.name}</span>
{u.key_gen !== serverGen && (
<span className="chip chip--error">
<span className="chip-dot" />
AWG {u.key_gen}
</span>
)}
</div> </div>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}> <span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" /> <span className="chip-dot" />
+10
View File
@@ -1,3 +1,8 @@
/*
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.
*/
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field, /* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */ table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
@@ -64,7 +69,12 @@
.user-card-actions { display: flex; gap: 8px; } .user-card-actions { display: flex; gap: 8px; }
.user-card-actions .btn--outline { flex: 1; } .user-card-actions .btn--outline { flex: 1; }
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
.user-gen { margin-left: 8px; vertical-align: middle; }
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */ /* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
@media (max-width: 640px) { @media (max-width: 640px) {
.user-cards-list { display: flex; } .user-cards-list { display: flex; }
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
} }
+3
View File
@@ -1 +1,4 @@
// 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.
/// <reference types="vite/client" /> /// <reference types="vite/client" />
+3
View File
@@ -1,3 +1,6 @@
// 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 { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.1.3+1", "version": "0.1.4+1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+1 -1
View File
@@ -287,7 +287,7 @@ async function interactiveMenu() {
const printMenu = () => { const printMenu = () => {
console.clear(); console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.3.1 ──${C.reset}`)}\n`); console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4.1 ──${C.reset}`)}\n`);
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
+172 -3
View File
@@ -19,7 +19,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" [[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh" [[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
VERSION="0.1.3.1" VERSION="0.1.4.1"
GH_REPO="maeneko/forgetting" GH_REPO="maeneko/forgetting"
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}" BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
PROJECT="/opt/awg-control" PROJECT="/opt/awg-control"
@@ -118,6 +118,10 @@ echo
# Ограничения, которые обязан соблюсти генератор: # Ограничения, которые обязан соблюсти генератор:
# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU # - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU
# - S1, S2: < ~150, в части версий S1 != S2 # - S1, S2: < ~150, в части версий S1 != S2
# - S1–S4: при заданном HeaderProtectionKey (AmneziaWG 3.x) НИ ОДНО из них
# не может быть меньше 12 (HEADER_PROTECTION_NONCE_SIZE). Модуль на
# нарушение отвечает только «Invalid argument», причина видна лишь при
# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`
# - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные # - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные
# типы сообщений WireGuard), большие uint32 без пересечений # типы сообщений WireGuard), большие uint32 без пересечений
# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами # - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами
@@ -447,6 +451,78 @@ else
ok "AWG установлен, модуль amneziawg собран и загружается" ok "AWG установлен, модуль amneziawg собран и загружается"
fi fi
# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5.
# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0,
# поэтому сначала пробуем обновиться.
# - ядро: 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"
kernel_lt_5_5() {
local maj min
maj=${KERNEL%%.*}
min=${KERNEL#*.}; min=${min%%.*}
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
}
# 🛑 Версий у модуля две, и путать их нельзя:
# modinfo — что лежит на диске (после apt/DKMS);
# /sys/module/amneziawg/version — что реально загружено в ядро.
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
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
MOD_VER=$(disk_mod_ver)
fi
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
# поэтому сначала опускаем его; 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
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
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."
fi
if [[ "$AWG3" == "y" ]]; then
ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)"
else
warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль и переустанови, чтобы перейти на 3.1."
fi
step "2/7 Node.js" step "2/7 Node.js"
if command -v node &>/dev/null; then if command -v node &>/dev/null; then
@@ -518,6 +594,38 @@ grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \
sysctl -qp sysctl -qp
ok "IP forwarding включён" ok "IP forwarding включён"
# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента
# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились.
# RandomTrailers/DisableCookies (фичи 3.1 от 12.08.2026) пока не включаем.
# 🛑 HeaderProtectionKey фиксируется на весь срок жизни сервера наравне с
# J/S/H: он обязан совпадать на обоих концах, и его смена делает невалидными
# все ранее выданные vpn:// ключи. При KEEP_DATA берём существующий.
# Поколение предыдущей установки — нужно, чтобы предупредить о перевыпуске
# ключей при KEEP_DATA. Считать обязательно ДО перезаписи конфига.
PREV_GEN="none"
if [[ -f "$AWG_CONF" ]]; then
if grep -q '^HeaderProtectionKey *=' "$AWG_CONF"; then PREV_GEN="3.1"; else PREV_GEN="2.0"; fi
fi
AWG3_LINES=""
if [[ "$AWG3" == "y" ]]; then
HEADER_PROTECTION_KEY=""
if [[ "$KEEP_DATA" == "y" && -f "$AWG_CONF" ]]; then
# sed, а не awk -F'=': base64-ключ сам содержит '=' в паддинге.
HEADER_PROTECTION_KEY=$(sed -n 's/^HeaderProtectionKey *= *//p' "$AWG_CONF" | head -1)
[[ -n "$HEADER_PROTECTION_KEY" ]] && ok "HeaderProtectionKey взят из существующего конфига"
fi
[[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk)
AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY}
ContentPaddingAddition = 10-100
RekeyAfterTime = 100-120
RekeyTimeout = 3-7
RejectAfterTime = 150-180
KeepaliveTimeout = 5-15
MaxHandshakeAttempts = 15-20"
fi
cat > "$AWG_CONF" <<CONF cat > "$AWG_CONF" <<CONF
[Interface] [Interface]
Address = ${SUBNET}.0.1/16 Address = ${SUBNET}.0.1/16
@@ -539,8 +647,18 @@ H3 = ${H3}
H4 = ${H4} H4 = ${H4}
CONF CONF
[[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF"
chmod 600 "$AWG_CONF" chmod 600 "$AWG_CONF"
ok "$AWG_CONF" NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0)
ok "$AWG_CONF (AmneziaWG $NEW_GEN)"
if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then
warn "Поколение протокола изменилось: $PREV_GEN$NEW_GEN."
warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах"
warn "и работать перестанут. После запуска открой панель и нажми"
warn "«Перевыпустить ключи», затем раздай пользователям новые ключи."
fi
step "5/7 Запуск AWG" step "5/7 Запуск AWG"
@@ -549,7 +667,41 @@ if awg show "$IFACE" &>/dev/null; then
awg-quick down "$IFACE" 2>/dev/null || true awg-quick down "$IFACE" 2>/dev/null || true
fi fi
awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой" # Подъём интерфейса — единственная настоящая проверка, что ядро приняло
# 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину
# печатает только в dmesg и только при включённом dynamic debug. Поэтому:
# упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не
# помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку.
AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *='
if ! awg-quick up "$IFACE"; then
ip link delete dev "$IFACE" 2>/dev/null || true
if [[ "$AWG3" != "y" ]]; then
fail "awg-quick up $IFACE завершился с ошибкой"
fi
warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину."
echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true
if awg-quick up "$IFACE"; then
ok "Со второй попытки интерфейс поднялся"
else
ip link delete dev "$IFACE" 2>/dev/null || true
echo " ── dmesg (последние 15 строк) ─────────────────"
dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true
echo " ───────────────────────────────────────────────"
cp "$AWG_CONF" "${AWG_CONF}.awg31"
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-quick up "$IFACE" \
|| fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше"
ok "Интерфейс поднят на AmneziaWG 2.0"
fi
fi
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp # awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не # (наблюдалось: awg show public-key = none сразу после up). Если ключ не
@@ -567,6 +719,23 @@ else
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none}$PUB_KEY" fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none}$PUB_KEY"
fi fi
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
# «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет».
# Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно.
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then
echo " → UFW активен, открываем маршрут и порты"
ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
ufw reload >/dev/null 2>&1 || true
ok "UFW: форвардинг $IFACE$NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
else
echo " → UFW не активен, правила фаервола не трогаем"
fi
step "6/7 npm install" step "6/7 npm install"
npm_install_all npm_install_all