- awg-ctrl: optional nft table awg_torrent (GET/PUT /api/torrent, off by default). A connection is judged by its first packets, like Xray's sniffing: DHT, UDP trackers, uTP SYN, and with @ih support the plain TCP handshake and HTTP announce. A match marks the connection in conntrack and drops it whole; without @ih only the UDP part is used - devices that tried are kept in a 10-minute set and flagged as "torrent" in user and device stats; with shaping on they sit in the background class while the flag lasts - metrics: awg_torrent_block_enabled/active, awg_torrent_blocked_total, awg_torrent_devices and per-peer awg_peer_torrent - panel: "Торренты" section in server settings, "торренты" chip on users and devices; Save sends only what changed
171 lines
8.3 KiB
TypeScript
171 lines
8.3 KiB
TypeScript
// 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';
|
|
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
|
|
export type Priority = 'normal' | 'background';
|
|
|
|
export interface User {
|
|
name: string;
|
|
ip: string;
|
|
pub_key: string;
|
|
vpn_key: string;
|
|
key_gen: AwgGen;
|
|
online: boolean;
|
|
lastHandshake: number;
|
|
rx?: number;
|
|
tx?: number;
|
|
priority?: Priority; // нет у awg-ctrl старше деления канала
|
|
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
|
|
}
|
|
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');
|
|
// Ключ внешнего API в списке вкладки: открытого значения тут нет — только
|
|
// префикс, сервер хранит лишь хэш.
|
|
export interface ApiKey {
|
|
id: number;
|
|
label: string;
|
|
prefix: string;
|
|
server_id: number; // сервер по умолчанию для /api/v1/users
|
|
servers: number[]; // какие серверы видит ключ
|
|
created_at: number;
|
|
last_used: number | null;
|
|
}
|
|
|
|
export interface PageProps {
|
|
token: string;
|
|
showMsg: (text: string) => void;
|
|
}
|
|
// Имя панели, канал и версия приходят с сервера (GET /ui/brand; значения — из
|
|
// .env в корне проекта) — переименование и бамп версии живут в одном файле.
|
|
// Эта константа — фолбэк на время загрузки и при недоступности роута.
|
|
export const BRAND_FALLBACK = 'Forgetting';
|
|
export interface Brand { brand: string; channel: string; version: string }
|
|
|
|
export const TOKEN_KEY = 'awg_token';
|
|
export const THEME_KEY = 'awg_theme';
|
|
export const SERVER_KEY = 'awg_server';
|
|
|
|
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
|
|
export interface NodeInfo {
|
|
id: number;
|
|
name: string;
|
|
local: boolean; // id 0 — локальный awg-ctrl этой же машины
|
|
online: boolean;
|
|
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
|
last_seen: number | null;
|
|
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
|
|
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
|
}
|
|
|
|
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
|
|
export interface ShapingStatus {
|
|
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
|
|
active: boolean; // CAKE реально стоит на интерфейсе
|
|
congested: boolean;
|
|
heavy: string[]; // IP устройств, переведённых в фоновый класс
|
|
devices: number; // активных устройств в окне
|
|
rateDown: number; // Мбит/с за последний тик
|
|
rateUp: number;
|
|
}
|
|
|
|
// Блокировка BitTorrent (GET/PUT /api/torrent на awg-ctrl сервера).
|
|
export interface TorrentStatus {
|
|
settings: { enabled: boolean };
|
|
active: boolean; // nft-таблица реально стоит
|
|
tcp: boolean; // проверяется и TCP-рукопожатие (нужен свежий nftables)
|
|
hits: number; // заблокированных попыток с последнего применения
|
|
seen: { ip: string; name: string }[]; // кто пытался за последние 10 минут
|
|
error?: string;
|
|
}
|
|
|
|
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
|
export function cmpVersion(a: string, b: string): number {
|
|
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
if (d) return d;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
|
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
|
// перечитывает данные уже с новым сервером.
|
|
let currentServerId: number | null = null;
|
|
export const setServerId = (id: number | null) => { currentServerId = id; };
|
|
export const getServerId = () => currentServerId;
|
|
|
|
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
|
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
|
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
|
|
const r = await axios({ method, url: path, headers, data: body });
|
|
return r.data;
|
|
}
|
|
|
|
export async function vpnKeyToConf(vpnKey: string): Promise<string> {
|
|
const b64 = vpnKey.replace('vpn://', '').replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
const stream = new Blob([raw.slice(4)]).stream().pipeThrough(new DecompressionStream('deflate'));
|
|
const json = await new Response(stream).text();
|
|
const data = JSON.parse(json);
|
|
const awg = data.containers[0].awg;
|
|
try {
|
|
const lc = JSON.parse(awg.last_config || '{}');
|
|
return (lc.config || '')
|
|
.replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1')
|
|
.replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1');
|
|
} catch {
|
|
return (awg.last_config || '')
|
|
.replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1')
|
|
.replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1');
|
|
}
|
|
}
|
|
export function downloadFile(filename: string, text: string) {
|
|
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
export async function copyText(text: string) {
|
|
if (navigator.clipboard) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.cssText = 'position:fixed;opacity:0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
}
|
|
export function bytes(n: number | undefined): string {
|
|
if (!n) return '—';
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
|
|
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
|
|
return (n / 1_073_741_824).toFixed(2) + ' GB';
|
|
}
|
|
export function timeAgo(ts: number | undefined): string {
|
|
if (!ts) return '—';
|
|
const sec = Math.floor(Date.now() / 1000) - ts;
|
|
if (sec < 60) return sec + ' сек';
|
|
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
|
|
return Math.floor(sec / 3600) + ' ч';
|
|
} |