3 Commits
Author SHA1 Message Date
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 420 additions and 50 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
+211 -28
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,8 +213,9 @@ 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 {
@@ -169,13 +224,21 @@ interface UserRow {
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",
"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",
"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</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",
"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 ──${C.reset}`)}\n`);
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
+90 -2
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"
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,48 @@ 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 ) ]]
}
MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "")
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=$(modinfo -F version amneziawg 2>/dev/null || echo "")
fi
if [[ "${MOD_VER%%.*}" != "3" ]]; then
AWG3="n"
warn "Модуль остался на версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
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 +564,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 +617,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"