mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 15:24:26 +00:00
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.
This commit is contained in:
+216
-33
@@ -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; }
|
||||||
|
|||||||
+8
-4
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+1
-1
@@ -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 ?? '?'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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-ключ (показывается один раз при создании) */
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 — стили вкладки (импортит сам компонент)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -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" />
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
+89
-1
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user