mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 16:24:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b097ccd5fb | ||
|
|
c0e19f284d | ||
|
|
6fdacd71c3 | ||
|
|
af5c0e1b43 | ||
|
|
01d8524f8c | ||
|
|
44c637f7af |
@@ -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
|
||||||
|
|||||||
+243
-30
@@ -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,49 @@ 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Версия модуля/tools для /health — показывает оператору, что реально работает
|
||||||
|
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
|
||||||
|
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
|
||||||
|
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
|
||||||
|
// это единственные моменты, когда модуль может реально смениться в течение
|
||||||
|
// жизни процесса; из обработчика /health не вызывается — этот роут без
|
||||||
|
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
|
||||||
|
function readAwgVersions(): { module: string; tools: string } {
|
||||||
|
let module = "";
|
||||||
|
try {
|
||||||
|
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
|
||||||
|
}
|
||||||
|
let tools = "";
|
||||||
|
try {
|
||||||
|
const out = run("awg --version");
|
||||||
|
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
|
||||||
|
} catch { /* awg-tools не найден */ }
|
||||||
|
return { module, tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
|
||||||
|
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
|
||||||
|
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
|
||||||
|
|
||||||
const runtimeConfig = initConfig();
|
const runtimeConfig = initConfig();
|
||||||
|
const AWG_PARAMS = readAwgParams();
|
||||||
|
const AWG_GEN = genOf(AWG_PARAMS);
|
||||||
|
const AWG_VERSIONS = readAwgVersions();
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
interface: "awg1",
|
interface: "awg1",
|
||||||
confDir: "/etc/amnezia/amneziawg",
|
confDir: "/etc/amnezia/amneziawg",
|
||||||
@@ -159,8 +236,13 @@ 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,
|
||||||
|
// Мутируются в restartAwg() — единственном месте, где модуль может
|
||||||
|
// реально смениться в течение жизни процесса.
|
||||||
|
awgModule: AWG_VERSIONS.module,
|
||||||
|
awgTools: AWG_VERSIONS.tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UserRow {
|
interface UserRow {
|
||||||
@@ -169,13 +251,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 +297,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 +310,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 +329,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 +349,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 +385,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 +431,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`);
|
||||||
@@ -418,7 +609,10 @@ function restartAwg() {
|
|||||||
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
||||||
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
|
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
|
||||||
syncPeers();
|
syncPeers();
|
||||||
logger.info("AWG restart: done");
|
const versions = readAwgVersions();
|
||||||
|
CONFIG.awgModule = versions.module;
|
||||||
|
CONFIG.awgTools = versions.tools;
|
||||||
|
logger.info("AWG restart: done", versions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInterface() {
|
function startInterface() {
|
||||||
@@ -430,36 +624,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,7 +726,8 @@ 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,
|
||||||
awg: { status: up ? "ok" : "down", peers },
|
gen: CONFIG.gen,
|
||||||
|
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -534,7 +740,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 +752,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 +767,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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "1.0.0",
|
"version": "0.1.4+2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.1.3+1",
|
"version": "0.1.4+2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+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;
|
||||||
@@ -404,6 +406,13 @@ body {
|
|||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
||||||
|
.server-gen-chip {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
.server-card-peers {
|
.server-card-peers {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
|
|||||||
+5
-2
@@ -36,7 +36,10 @@ 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',
|
||||||
|
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
|
||||||
|
});
|
||||||
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -214,7 +217,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.2</span>
|
||||||
</div>
|
</div>
|
||||||
<hr className="drawer-divider" />
|
<hr className="drawer-divider" />
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
import { type ServerInfo } from '../lib/shared';
|
// 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, genLabel } from '../lib/shared';
|
||||||
import { IcoPlus, IcoRefresh } from './icons';
|
import { IcoPlus, IcoRefresh } from './icons';
|
||||||
|
|
||||||
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
|
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
|
||||||
@@ -36,6 +38,16 @@ export default function ServerBar({
|
|||||||
<div className="server-card-info">
|
<div className="server-card-info">
|
||||||
<span className="server-card-name">{serverInfo.name}</span>
|
<span className="server-card-name">{serverInfo.name}</span>
|
||||||
<span className="server-card-ip">{serverInfo.ip}</span>
|
<span className="server-card-ip">{serverInfo.ip}</span>
|
||||||
|
{(serverInfo.mod || serverInfo.tools) ? (
|
||||||
|
<span
|
||||||
|
className="tip-wrap server-gen-wrap"
|
||||||
|
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
|
||||||
|
>
|
||||||
|
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,7 +20,12 @@ export interface ServerInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
peers: number;
|
peers: number;
|
||||||
|
gen: AwgGen;
|
||||||
|
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
|
||||||
|
tools: string; // версия awg-tools, из /health awg.tools
|
||||||
}
|
}
|
||||||
|
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
|
||||||
|
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -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, genLabel,
|
||||||
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} шт.) на поколении ${genLabel(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={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
|
||||||
|
>
|
||||||
|
<span className="chip chip--error">
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{genLabel(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" />
|
||||||
|
{genLabel(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';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.1.3+1",
|
"version": "0.1.4+2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+31
-4
@@ -196,7 +196,27 @@ async function restart(name: SvcName) {
|
|||||||
await start(name);
|
await start(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function status() {
|
// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
|
||||||
|
// молча даёт null — health-строка в status() тогда просто не печатается.
|
||||||
|
interface HealthBody {
|
||||||
|
gen?: string;
|
||||||
|
awg?: { module?: string; tools?: string };
|
||||||
|
}
|
||||||
|
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const req = http.get(url, res => {
|
||||||
|
let body = "";
|
||||||
|
res.on("data", (c: Buffer) => { body += c; });
|
||||||
|
res.on("end", () => {
|
||||||
|
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", () => resolve(null));
|
||||||
|
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status() {
|
||||||
console.log("");
|
console.log("");
|
||||||
for (const name of ALL) {
|
for (const name of ALL) {
|
||||||
const running = isRunning(name);
|
const running = isRunning(name);
|
||||||
@@ -205,6 +225,13 @@ function status() {
|
|||||||
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
|
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
|
||||||
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
|
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
|
||||||
}
|
}
|
||||||
|
if (isRunning("awgctrl")) {
|
||||||
|
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
|
||||||
|
if (h?.gen) {
|
||||||
|
const gen = h.gen === "3.1" ? "3.1" : "2.0";
|
||||||
|
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log("");
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +314,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.2 ──${C.reset}`)}\n`);
|
||||||
|
|
||||||
for (const name of ALL) {
|
for (const name of ALL) {
|
||||||
const running = isRunning(name);
|
const running = isRunning(name);
|
||||||
@@ -321,7 +348,7 @@ async function interactiveMenu() {
|
|||||||
case "1": await startAll(); break;
|
case "1": await startAll(); break;
|
||||||
case "2": await stopAll(); break;
|
case "2": await stopAll(); break;
|
||||||
case "3": await restartAll(); break;
|
case "3": await restartAll(); break;
|
||||||
case "4": status(); break;
|
case "4": await status(); break;
|
||||||
case "5": await setCredentials("user"); break;
|
case "5": await setCredentials("user"); break;
|
||||||
case "6": await setCredentials("pass"); break;
|
case "6": await setCredentials("pass"); break;
|
||||||
case "0": rl.close(); process.exit(0);
|
case "0": rl.close(); process.exit(0);
|
||||||
@@ -350,7 +377,7 @@ async function main() {
|
|||||||
case "start": await startAll(); break;
|
case "start": await startAll(); break;
|
||||||
case "stop": await stopAll(); break;
|
case "stop": await stopAll(); break;
|
||||||
case "restart": await restartAll(); break;
|
case "restart": await restartAll(); break;
|
||||||
case "status": status(); break;
|
case "status": await status(); break;
|
||||||
case "credentials":
|
case "credentials":
|
||||||
if (sub === "user") await setCredentials("user");
|
if (sub === "user") await setCredentials("user");
|
||||||
else if (sub === "pass") await setCredentials("pass");
|
else if (sub === "pass") await setCredentials("pass");
|
||||||
|
|||||||
+222
-3
@@ -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.2"
|
||||||
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:// ключ пустыми плейсхолдерами
|
||||||
@@ -130,6 +134,22 @@ H2="2122053282-2133204808"
|
|||||||
H3="2133604274-2140756116"
|
H3="2133604274-2140756116"
|
||||||
H4="2143656228-2147444225"
|
H4="2143656228-2147444225"
|
||||||
|
|
||||||
|
# Поколение протокола и закрепление версии пакета — управляются переменными
|
||||||
|
# окружения, задаются ДО запуска: `AWG_GEN=2.0 bash install.sh`.
|
||||||
|
# AWG_GEN=auto (по умолчанию) — как раньше: 3.1, если модуль 3.x и ядро ≥ 5.5,
|
||||||
|
# иначе 2.0, без лишних вопросов.
|
||||||
|
# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0.
|
||||||
|
# AWG_GEN=3.1 — требовать 3.1: если условия не выполняются, install.sh падает
|
||||||
|
# с диагностикой вместо тихого отката (апстрим ещё нестабилен, см. issues
|
||||||
|
# #215/#216/#217 в amneziawg-linux-kernel-module — тихий откат на 2.0 здесь
|
||||||
|
# нежелателен, если оператор явно просил 3.1).
|
||||||
|
AWG_GEN="${AWG_GEN:-auto}"
|
||||||
|
[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)"
|
||||||
|
# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools}
|
||||||
|
# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым
|
||||||
|
# сервером (issue #215 — именно так словили регрессию между dkms-сборками).
|
||||||
|
AWG_PIN="${AWG_PIN:-y}"
|
||||||
|
|
||||||
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
|
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
|
||||||
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
|
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
|
||||||
|
|
||||||
@@ -447,6 +467,96 @@ 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 недоступен и AWG_GEN=auto — не падаем, а пишем 2.0-конфиг: awg-ctrl
|
||||||
|
# определяет поколение по наличию HeaderProtectionKey в конфиге, так что всё
|
||||||
|
# продолжит работать ровно как раньше. При AWG_GEN=3.1 недоступность — fail
|
||||||
|
# (см. AWG_GEN выше): апстрим модуля ещё нестабилен, тихий откат при явном
|
||||||
|
# запросе 3.1 может незаметно подсунуть более слабую обфускацию.
|
||||||
|
|
||||||
|
kernel_lt_5_5() {
|
||||||
|
local maj min
|
||||||
|
maj=${KERNEL%%.*}
|
||||||
|
min=${KERNEL#*.}; min=${min%%.*}
|
||||||
|
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 🛑 Версий у модуля две, и путать их нельзя:
|
||||||
|
# modinfo — что лежит на диске (после apt/DKMS);
|
||||||
|
# /sys/module/amneziawg/version — что реально загружено в ядро.
|
||||||
|
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
|
||||||
|
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
|
||||||
|
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
|
||||||
|
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
|
||||||
|
disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; }
|
||||||
|
loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; }
|
||||||
|
|
||||||
|
if [[ "$AWG_GEN" == "2.0" ]]; then
|
||||||
|
AWG3="n"
|
||||||
|
MOD_VER=$(disk_mod_ver)
|
||||||
|
ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0."
|
||||||
|
else
|
||||||
|
AWG3="y"
|
||||||
|
|
||||||
|
MOD_VER=$(disk_mod_ver)
|
||||||
|
if [[ "${MOD_VER%%.*}" != "3" ]]; then
|
||||||
|
warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить."
|
||||||
|
# Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) —
|
||||||
|
# снимаем, иначе --only-upgrade молча не тронет held-версию.
|
||||||
|
apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||||
|
apt-get update >/dev/null 2>&1 || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
|
||||||
|
apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||||
|
MOD_VER=$(disk_mod_ver)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
|
||||||
|
# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен.
|
||||||
|
LOADED_VER=$(loaded_mod_ver)
|
||||||
|
if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then
|
||||||
|
warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль."
|
||||||
|
awg-quick down "$IFACE" 2>/dev/null || true
|
||||||
|
ip link delete dev "$IFACE" 2>/dev/null || true
|
||||||
|
if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then
|
||||||
|
LOADED_VER=$(loaded_mod_ver)
|
||||||
|
ok "Модуль перезагружен: в ядре теперь $LOADED_VER"
|
||||||
|
else
|
||||||
|
warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
|
||||||
|
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
|
||||||
|
|
||||||
|
if [[ "${MOD_VER%%.*}" != "3" ]]; then
|
||||||
|
AWG3="n"
|
||||||
|
if [[ "$AWG_GEN" == "3.1" ]]; then
|
||||||
|
fail "AWG_GEN=3.1 запрошен явно, но в ядре модуль версии '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)) — нужна 3.x."
|
||||||
|
fi
|
||||||
|
warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
|
||||||
|
[[ "$(disk_mod_ver)" == 3.* ]] && \
|
||||||
|
warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1."
|
||||||
|
elif kernel_lt_5_5; then
|
||||||
|
AWG3="n"
|
||||||
|
if [[ "$AWG_GEN" == "3.1" ]]; then
|
||||||
|
fail "AWG_GEN=3.1 запрошен явно, но ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
|
||||||
|
fi
|
||||||
|
warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
|
||||||
|
warn "Ставим сервер на AmneziaWG 2.0."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$AWG3" == "y" ]]; then
|
||||||
|
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 +628,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 +681,18 @@ H3 = ${H3}
|
|||||||
H4 = ${H4}
|
H4 = ${H4}
|
||||||
CONF
|
CONF
|
||||||
|
|
||||||
|
[[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF"
|
||||||
|
|
||||||
chmod 600 "$AWG_CONF"
|
chmod 600 "$AWG_CONF"
|
||||||
ok "$AWG_CONF"
|
NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0)
|
||||||
|
ok "$AWG_CONF (AmneziaWG $NEW_GEN)"
|
||||||
|
|
||||||
|
if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then
|
||||||
|
warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN."
|
||||||
|
warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах"
|
||||||
|
warn "и работать перестанут. После запуска открой панель и нажми"
|
||||||
|
warn "«Перевыпустить ключи», затем раздай пользователям новые ключи."
|
||||||
|
fi
|
||||||
|
|
||||||
step "5/7 Запуск AWG"
|
step "5/7 Запуск AWG"
|
||||||
|
|
||||||
@@ -549,7 +701,43 @@ if awg show "$IFACE" &>/dev/null; then
|
|||||||
awg-quick down "$IFACE" 2>/dev/null || true
|
awg-quick down "$IFACE" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой"
|
# Подъём интерфейса — единственная настоящая проверка, что ядро приняло
|
||||||
|
# 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину
|
||||||
|
# печатает только в dmesg и только при включённом dynamic debug. Поэтому:
|
||||||
|
# упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не
|
||||||
|
# помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку.
|
||||||
|
AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *='
|
||||||
|
|
||||||
|
if ! awg-quick up "$IFACE"; then
|
||||||
|
ip link delete dev "$IFACE" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ "$AWG3" != "y" ]]; then
|
||||||
|
fail "awg-quick up $IFACE завершился с ошибкой"
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину."
|
||||||
|
echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true
|
||||||
|
|
||||||
|
if awg-quick up "$IFACE"; then
|
||||||
|
ok "Со второй попытки интерфейс поднялся"
|
||||||
|
else
|
||||||
|
ip link delete dev "$IFACE" 2>/dev/null || true
|
||||||
|
echo " ── dmesg (последние 15 строк) ─────────────────"
|
||||||
|
dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true
|
||||||
|
echo " ───────────────────────────────────────────────"
|
||||||
|
|
||||||
|
cp "$AWG_CONF" "${AWG_CONF}.awg31"
|
||||||
|
sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF"
|
||||||
|
AWG3="n"; NEW_GEN="2.0"
|
||||||
|
warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31"
|
||||||
|
[[ "$AWG_GEN" == "3.1" ]] && \
|
||||||
|
warn "Запрошено AWG_GEN=3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше."
|
||||||
|
|
||||||
|
awg-quick up "$IFACE" \
|
||||||
|
|| fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше"
|
||||||
|
ok "Интерфейс поднят на AmneziaWG 2.0"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
|
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
|
||||||
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не
|
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не
|
||||||
@@ -567,6 +755,37 @@ else
|
|||||||
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
|
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}')
|
||||||
|
|
||||||
|
# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под
|
||||||
|
# живым сервером (issue #215: между двумя dkms-сборками словили регрессию —
|
||||||
|
# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше;
|
||||||
|
# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся.
|
||||||
|
if [[ "$AWG_PIN" == "y" ]]; then
|
||||||
|
apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||||
|
ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0")
|
||||||
|
ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")"
|
||||||
|
|
||||||
|
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
|
||||||
|
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
|
||||||
|
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
|
||||||
|
# «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет».
|
||||||
|
# Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно.
|
||||||
|
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then
|
||||||
|
echo " → UFW активен, открываем маршрут и порты"
|
||||||
|
ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \
|
||||||
|
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
|
||||||
|
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
|
||||||
|
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
|
||||||
|
ufw reload >/dev/null 2>&1 || true
|
||||||
|
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
|
||||||
|
else
|
||||||
|
echo " → UFW не активен, правила фаервола не трогаем"
|
||||||
|
fi
|
||||||
|
|
||||||
step "6/7 npm install"
|
step "6/7 npm install"
|
||||||
|
|
||||||
npm_install_all
|
npm_install_all
|
||||||
|
|||||||
Reference in New Issue
Block a user