mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 15:24:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b097ccd5fb | ||
|
|
c0e19f284d | ||
|
|
6fdacd71c3 | ||
|
|
af5c0e1b43 | ||
|
|
01d8524f8c | ||
|
|
44c637f7af |
@@ -37,4 +37,4 @@ jobs:
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
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)");
|
||||
|
||||
// Миграции 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(`
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -106,12 +121,29 @@ function initConfig() {
|
||||
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;
|
||||
S1: number; S2: number; S3: number; S4: number;
|
||||
H1: string; H2: string; H3: string; H4: string;
|
||||
I1: string; I2: string; I3: string; I4: string; I5: string;
|
||||
}
|
||||
} & Record<Awg3Key, string>;
|
||||
|
||||
const DEFAULT_AWG_PARAMS: AwgParams = {
|
||||
Jc: 6, Jmin: 10, Jmax: 50,
|
||||
@@ -122,6 +154,11 @@ const DEFAULT_AWG_PARAMS: AwgParams = {
|
||||
H4: "2143656228-2147444225",
|
||||
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
|
||||
I2: "", I3: "", I4: "", I5: "",
|
||||
// 3.x по умолчанию выключен: без conf-а сервер остаётся на 2.0.
|
||||
HeaderProtectionKey: "", ContentPaddingAddition: "",
|
||||
RekeyAfterTime: "", RekeyTimeout: "", RejectAfterTime: "",
|
||||
KeepaliveTimeout: "", MaxHandshakeAttempts: "",
|
||||
RandomTrailers: "", DisableCookies: "",
|
||||
};
|
||||
|
||||
function readAwgParams(): AwgParams {
|
||||
@@ -134,7 +171,9 @@ function readAwgParams(): AwgParams {
|
||||
const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0];
|
||||
|
||||
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) {
|
||||
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"));
|
||||
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;
|
||||
}
|
||||
|
||||
// Поколение протокола выводим из самих параметров, отдельного флага нет: 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 AWG_PARAMS = readAwgParams();
|
||||
const AWG_GEN = genOf(AWG_PARAMS);
|
||||
const AWG_VERSIONS = readAwgVersions();
|
||||
const CONFIG = {
|
||||
interface: "awg1",
|
||||
confDir: "/etc/amnezia/amneziawg",
|
||||
@@ -159,8 +236,13 @@ const CONFIG = {
|
||||
dns1: "1.1.1.1",
|
||||
dns2: "1.0.0.1",
|
||||
mtu: 1376,
|
||||
keepalive: 25,
|
||||
awgParams: readAwgParams(),
|
||||
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
|
||||
awgParams: AWG_PARAMS,
|
||||
gen: AWG_GEN,
|
||||
// Мутируются в restartAwg() — единственном месте, где модуль может
|
||||
// реально смениться в течение жизни процесса.
|
||||
awgModule: AWG_VERSIONS.module,
|
||||
awgTools: AWG_VERSIONS.tools,
|
||||
};
|
||||
|
||||
interface UserRow {
|
||||
@@ -169,13 +251,21 @@ interface UserRow {
|
||||
pub_key: string;
|
||||
vpn_key: string;
|
||||
psk_key: string;
|
||||
key_gen: string;
|
||||
vpn_key_prev: string;
|
||||
}
|
||||
|
||||
const stmts = {
|
||||
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 = ?"),
|
||||
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 {
|
||||
@@ -207,7 +297,10 @@ function nextIp(): string {
|
||||
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 = » с ОДНИМ хвостовым пробелом
|
||||
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
|
||||
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
|
||||
@@ -217,6 +310,7 @@ function buildClientConf(
|
||||
serverPub: string,
|
||||
): string {
|
||||
const p = CONFIG.awgParams;
|
||||
const awg3 = AWG3_KEYS.filter(k => p[k]).map(k => `${k} = ${p[k]}\n`).join("");
|
||||
return `[Interface]
|
||||
PrivateKey = ${keys.privateKey}
|
||||
Jc = ${p.Jc}
|
||||
@@ -235,7 +329,7 @@ I2 =${" "}
|
||||
I3 =${" "}
|
||||
I4 =${" "}
|
||||
I5 =${" "}
|
||||
Address = ${ip}/32
|
||||
${awg3}Address = ${ip}/32
|
||||
DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
|
||||
|
||||
[Peer]
|
||||
@@ -255,12 +349,29 @@ function encodeVpnKey(
|
||||
const p = CONFIG.awgParams;
|
||||
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,
|
||||
HeaderProtectionKey: p.HeaderProtectionKey,
|
||||
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
|
||||
Jc: String(p.Jc),
|
||||
Jmax: String(p.Jmax),
|
||||
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),
|
||||
allowed_ips: ["0.0.0.0/0", "::/0"],
|
||||
clientId: keys.publicKey,
|
||||
@@ -274,25 +385,34 @@ function encodeVpnKey(
|
||||
port: CONFIG.serverPort,
|
||||
psk_key: keys.presharedKey,
|
||||
server_pub_key: serverPub,
|
||||
};
|
||||
});
|
||||
|
||||
const json = JSON.stringify({
|
||||
containers: [{
|
||||
container: "amnezia-awg2",
|
||||
awg: {
|
||||
awg: dropEmptyAwg3({
|
||||
ContentPaddingAddition: p.ContentPaddingAddition,
|
||||
DisableCookies: p.DisableCookies,
|
||||
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
|
||||
HeaderProtectionKey: p.HeaderProtectionKey,
|
||||
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
|
||||
Jc: String(p.Jc),
|
||||
Jmax: String(p.Jmax),
|
||||
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),
|
||||
last_config: JSON.stringify(lastConfigObj, null, 2),
|
||||
port: String(CONFIG.serverPort),
|
||||
protocol_version: "2",
|
||||
protocol_version: CONFIG.gen,
|
||||
subnet_address: `${CONFIG.subnet}.0.0`,
|
||||
transport_proto: "udp",
|
||||
},
|
||||
}),
|
||||
}],
|
||||
defaultContainer: "amnezia-awg2",
|
||||
description: CONFIG.serverName,
|
||||
@@ -311,6 +431,77 @@ function encodeVpnKey(
|
||||
.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 }> {
|
||||
try {
|
||||
const output = run(`awg show ${CONFIG.interface} dump`);
|
||||
@@ -418,7 +609,10 @@ function restartAwg() {
|
||||
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
|
||||
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
|
||||
syncPeers();
|
||||
logger.info("AWG restart: done");
|
||||
const versions = readAwgVersions();
|
||||
CONFIG.awgModule = versions.module;
|
||||
CONFIG.awgTools = versions.tools;
|
||||
logger.info("AWG restart: done", versions);
|
||||
}
|
||||
|
||||
function startInterface() {
|
||||
@@ -430,36 +624,47 @@ function startInterface() {
|
||||
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 {
|
||||
const keys = generateKeys();
|
||||
const serverPub = getServerPublicKey();
|
||||
|
||||
const ip = db.transaction(() => {
|
||||
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;
|
||||
})();
|
||||
|
||||
const vpn_key = encodeVpnKey(keys, ip, serverPub);
|
||||
db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username);
|
||||
|
||||
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
|
||||
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 {}
|
||||
const r = setPeer(keys.publicKey, keys.presharedKey, ip);
|
||||
if (r.status !== 0) {
|
||||
stmts.delete.run(username);
|
||||
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
|
||||
}
|
||||
|
||||
rebuildConf();
|
||||
logger.info("user created", { name: username, ip });
|
||||
return { name: username, ip, pub_key: keys.publicKey, vpn_key, psk_key: keys.presharedKey };
|
||||
logger.info("user created", { name: username, ip, gen: CONFIG.gen });
|
||||
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) {
|
||||
@@ -521,7 +726,8 @@ app.get("/health", (_req, res) => {
|
||||
status: up ? "ok" : "degraded",
|
||||
server: CONFIG.serverName,
|
||||
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) => {
|
||||
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();
|
||||
res.json({
|
||||
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) => {
|
||||
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();
|
||||
res.json({
|
||||
users: users.map(u => ({
|
||||
name: u.name,
|
||||
ip: u.ip,
|
||||
key_gen: u.key_gen,
|
||||
online: peers[u.pub_key]?.online ?? false,
|
||||
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 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) => {
|
||||
const user = stmts.get.get(req.params.name);
|
||||
if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.4+2",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.1.3+1",
|
||||
"version": "0.1.4+2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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();
|
||||
ext.use(requireApiKey);
|
||||
|
||||
@@ -217,8 +221,8 @@ ext.post("/users", async (req: Request, res: Response) => {
|
||||
const { name } = (req.body ?? {}) as { name?: string };
|
||||
const r = await ctrl("POST", "/api/users", { name });
|
||||
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||
const u = r.data as { name: string; ip: string; vpn_key: string };
|
||||
res.status(201).json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
|
||||
const u = r.data as ExtUser;
|
||||
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) => {
|
||||
@@ -229,8 +233,8 @@ ext.get("/users", 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)}`);
|
||||
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||
const u = r.data as { name: string; ip: string; vpn_key: string };
|
||||
res.json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
|
||||
const u = r.data as ExtUser;
|
||||
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) => {
|
||||
|
||||
@@ -352,6 +352,8 @@ body {
|
||||
.chip--online .chip-dot { background: var(--success-dot); }
|
||||
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
|
||||
.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 {
|
||||
flex: 1;
|
||||
@@ -404,6 +406,13 @@ body {
|
||||
color: var(--neutral-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
||||
.server-gen-chip {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.server-card-peers {
|
||||
font-size: 12px;
|
||||
color: var(--neutral-text);
|
||||
|
||||
+5
-2
@@ -36,7 +36,10 @@ export default function App() {
|
||||
// вкладок (юзеры, ключи) грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 });
|
||||
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 ?? '?'));
|
||||
}, []);
|
||||
|
||||
@@ -214,7 +217,7 @@ export default function App() {
|
||||
>
|
||||
<div className="sidebar-logo">
|
||||
<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>
|
||||
<hr className="drawer-divider" />
|
||||
{TABS.map(t => (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
import { type ServerInfo } from '../lib/shared';
|
||||
// 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, genLabel } from '../lib/shared';
|
||||
import { IcoPlus, IcoRefresh } from './icons';
|
||||
|
||||
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
|
||||
@@ -36,6 +38,16 @@ export default function ServerBar({
|
||||
<div className="server-card-info">
|
||||
<span className="server-card-name">{serverInfo.name}</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>
|
||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||
</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). Добавляя вкладку — добавь сюда
|
||||
// её иконку и зарегистрируй имя в 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';
|
||||
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
||||
export type AwgGen = '2' | '3.1';
|
||||
export interface User {
|
||||
name: string;
|
||||
ip: string;
|
||||
pub_key: string;
|
||||
vpn_key: string;
|
||||
key_gen: AwgGen;
|
||||
online: boolean;
|
||||
lastHandshake: number;
|
||||
rx?: number;
|
||||
@@ -14,7 +20,12 @@ export interface ServerInfo {
|
||||
name: string;
|
||||
ip: string;
|
||||
peers: number;
|
||||
gen: AwgGen;
|
||||
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
|
||||
tools: string; // версия awg-tools, из /health awg.tools
|
||||
}
|
||||
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
|
||||
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
label: string;
|
||||
|
||||
@@ -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 { createRoot } from 'react-dom/client';
|
||||
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-ключ (показывается один раз при создании) */
|
||||
|
||||
@@ -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 { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
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>/:
|
||||
// index.tsx — компонент + логика
|
||||
// <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 QRCode from 'qrcode';
|
||||
import {
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
|
||||
type User, type PageProps,
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
|
||||
type User, type PageProps, type AwgGen,
|
||||
} from '../../lib/shared';
|
||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||
import './users.css';
|
||||
@@ -13,7 +16,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
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 stale = users.filter(u => u.key_gen !== serverGen);
|
||||
|
||||
const loadStats = useCallback(async (tok: string) => {
|
||||
try {
|
||||
@@ -27,14 +34,34 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
|
||||
const loadUsers = useCallback(async (tok: string) => {
|
||||
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 ?? []);
|
||||
setServerGen(health.gen ?? '2');
|
||||
await loadStats(tok);
|
||||
} catch {
|
||||
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 () => {
|
||||
if (!newName) return;
|
||||
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)}>
|
||||
<IcoRefresh /> Обновить
|
||||
</button>
|
||||
{stale.length > 0 && (
|
||||
<button className="btn btn--danger" onClick={reissueKeys}>
|
||||
<IcoRefresh /> Перевыпустить ключи ({stale.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +157,20 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</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>{bytes(u.rx)}</td>
|
||||
<td>{bytes(u.tx)}</td>
|
||||
@@ -159,6 +204,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<div className="user-card-title">
|
||||
<span className="user-card-num">#{i + 1}</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>
|
||||
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
||||
<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,
|
||||
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
|
||||
|
||||
@@ -64,7 +69,12 @@
|
||||
.user-card-actions { display: flex; gap: 8px; }
|
||||
.user-card-actions .btn--outline { flex: 1; }
|
||||
|
||||
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
|
||||
.user-gen { margin-left: 8px; vertical-align: middle; }
|
||||
|
||||
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
||||
@media (max-width: 640px) {
|
||||
.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" />
|
||||
|
||||
@@ -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 react from '@vitejs/plugin-react';
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.1.3+1",
|
||||
"version": "0.1.4+2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+31
-4
@@ -196,7 +196,27 @@ async function restart(name: SvcName) {
|
||||
await start(name);
|
||||
}
|
||||
|
||||
function status() {
|
||||
// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
|
||||
// молча даёт null — health-строка в status() тогда просто не печатается.
|
||||
interface HealthBody {
|
||||
gen?: string;
|
||||
awg?: { module?: string; tools?: string };
|
||||
}
|
||||
function fetchHealth(url: string, timeoutMs = 1000): Promise<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("");
|
||||
for (const name of ALL) {
|
||||
const running = isRunning(name);
|
||||
@@ -205,6 +225,13 @@ function status() {
|
||||
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
|
||||
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
|
||||
}
|
||||
if (isRunning("awgctrl")) {
|
||||
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
|
||||
if (h?.gen) {
|
||||
const gen = h.gen === "3.1" ? "3.1" : "2.0";
|
||||
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
@@ -287,7 +314,7 @@ async function interactiveMenu() {
|
||||
|
||||
const printMenu = () => {
|
||||
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) {
|
||||
const running = isRunning(name);
|
||||
@@ -321,7 +348,7 @@ async function interactiveMenu() {
|
||||
case "1": await startAll(); break;
|
||||
case "2": await stopAll(); break;
|
||||
case "3": await restartAll(); break;
|
||||
case "4": status(); break;
|
||||
case "4": await status(); break;
|
||||
case "5": await setCredentials("user"); break;
|
||||
case "6": await setCredentials("pass"); break;
|
||||
case "0": rl.close(); process.exit(0);
|
||||
@@ -350,7 +377,7 @@ async function main() {
|
||||
case "start": await startAll(); break;
|
||||
case "stop": await stopAll(); break;
|
||||
case "restart": await restartAll(); break;
|
||||
case "status": status(); break;
|
||||
case "status": await status(); break;
|
||||
case "credentials":
|
||||
if (sub === "user") await setCredentials("user");
|
||||
else if (sub === "pass") await setCredentials("pass");
|
||||
|
||||
+222
-3
@@ -19,7 +19,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
|
||||
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo 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"
|
||||
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
|
||||
PROJECT="/opt/awg-control"
|
||||
@@ -118,6 +118,10 @@ echo
|
||||
# Ограничения, которые обязан соблюсти генератор:
|
||||
# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU
|
||||
# - 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 (зарезервированные
|
||||
# типы сообщений WireGuard), большие uint32 без пересечений
|
||||
# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами
|
||||
@@ -130,6 +134,22 @@ H2="2122053282-2133204808"
|
||||
H3="2133604274-2140756116"
|
||||
H4="2143656228-2147444225"
|
||||
|
||||
# Поколение протокола и закрепление версии пакета — управляются переменными
|
||||
# окружения, задаются ДО запуска: `AWG_GEN=2.0 bash install.sh`.
|
||||
# AWG_GEN=auto (по умолчанию) — как раньше: 3.1, если модуль 3.x и ядро ≥ 5.5,
|
||||
# иначе 2.0, без лишних вопросов.
|
||||
# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0.
|
||||
# AWG_GEN=3.1 — требовать 3.1: если условия не выполняются, install.sh падает
|
||||
# с диагностикой вместо тихого отката (апстрим ещё нестабилен, см. issues
|
||||
# #215/#216/#217 в amneziawg-linux-kernel-module — тихий откат на 2.0 здесь
|
||||
# нежелателен, если оператор явно просил 3.1).
|
||||
AWG_GEN="${AWG_GEN:-auto}"
|
||||
[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)"
|
||||
# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools}
|
||||
# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым
|
||||
# сервером (issue #215 — именно так словили регрессию между dkms-сборками).
|
||||
AWG_PIN="${AWG_PIN:-y}"
|
||||
|
||||
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
|
||||
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
|
||||
|
||||
@@ -447,6 +467,96 @@ else
|
||||
ok "AWG установлен, модуль amneziawg собран и загружается"
|
||||
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"
|
||||
|
||||
if command -v node &>/dev/null; then
|
||||
@@ -518,6 +628,38 @@ grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \
|
||||
sysctl -qp
|
||||
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
|
||||
[Interface]
|
||||
Address = ${SUBNET}.0.1/16
|
||||
@@ -539,8 +681,18 @@ H3 = ${H3}
|
||||
H4 = ${H4}
|
||||
CONF
|
||||
|
||||
[[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$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"
|
||||
|
||||
@@ -549,7 +701,43 @@ if awg show "$IFACE" &>/dev/null; then
|
||||
awg-quick down "$IFACE" 2>/dev/null || true
|
||||
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 show public-key = none сразу после up). Если ключ не
|
||||
@@ -567,6 +755,37 @@ else
|
||||
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
|
||||
fi
|
||||
|
||||
TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}')
|
||||
|
||||
# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под
|
||||
# живым сервером (issue #215: между двумя dkms-сборками словили регрессию —
|
||||
# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше;
|
||||
# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся.
|
||||
if [[ "$AWG_PIN" == "y" ]]; then
|
||||
apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
|
||||
ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools"
|
||||
fi
|
||||
|
||||
GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0")
|
||||
ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")"
|
||||
|
||||
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
|
||||
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
|
||||
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
|
||||
# «[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"
|
||||
|
||||
npm_install_all
|
||||
|
||||
Reference in New Issue
Block a user