Browse Source

almost working version

pull/2684/head
pavelrzn 1 week ago
parent
commit
906e6a5847
  1. 1
      src/app/pages/admin.vue
  2. 344
      src/app/pages/admin/speed.vue
  3. 21
      src/i18n/locales/en.json
  4. 21
      src/i18n/locales/ru.json
  5. 21
      src/server/api/tc/state/index.get.ts
  6. 65
      src/server/api/tc/state/index.post.ts
  7. 8
      src/server/database/migrations/0007_add_tc_state.sql
  8. 69
      src/server/database/migrations/meta/0007_snapshot.json
  9. 7
      src/server/database/migrations/meta/_journal.json
  10. 26
      src/server/database/repositories/tcState/schema.ts
  11. 76
      src/server/database/repositories/tcState/service.ts
  12. 27
      src/server/database/repositories/tcState/types.ts
  13. 1
      src/server/database/schema.ts
  14. 4
      src/server/database/sqlite.ts
  15. 192
      src/server/utils/TCManager.ts
  16. 14
      src/server/utils/WireGuard.ts

1
src/app/pages/admin.vue

@ -59,6 +59,7 @@ const menuItems = computed(() => [
{ id: 'config', name: t('pages.admin.config') },
{ id: 'interface', name: t('pages.admin.interface') },
{ id: 'hooks', name: t('pages.admin.hooks') },
{ id: 'speed', name: t('pages.admin.speed') },
]);
const defaultItem = { id: '', name: t('pages.admin.panel') };

344
src/app/pages/admin/speed.vue

@ -0,0 +1,344 @@
<template>
<main v-if="data">
<div>
<!-- Header -->
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-xl font-semibold text-gray-800 dark:text-neutral-100">
{{ $t('admin.speed.title') }}
</h2>
<div class="flex flex-wrap gap-3">
<button role="button" class="inline-flex items-center rounded border-2 border-gray-100 px-4 py-2 text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white dark:border-neutral-600 dark:text-neutral-200" @click="refresh">
{{ $t('admin.speed.refresh') }}
</button>
<button role="button" class="inline-flex items-center rounded border-2 border-red-800 bg-red-800 px-4 py-2 text-white transition hover:border-red-600 hover:bg-red-600 inline-flex items-center gap-2" @click="applyConfig">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg>
{{ $t('admin.speed.apply') }}
</button>
</div>
</div>
<!-- Global Settings -->
<div class="mb-6 grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-neutral-600 dark:bg-neutral-800 sm:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-neutral-300">
{{ $t('admin.speed.totalBandwidth') }}
</label>
<div class="flex items-center gap-3">
<input
v-model.number="data.tcState.totalUlRate"
type="number"
min="1"
max="10000"
class="w-32 rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
>
<span class="text-sm text-gray-500 dark:text-neutral-400">Mbit</span>
<span class="ml-2 hidden text-xs text-gray-400 dark:text-neutral-500 lg:inline">
{{ $t('admin.speed.totalBandwidthDesc') }}
</span>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-neutral-300">
{{ $t('admin.speed.defaultClassId') }}
</label>
<div class="flex items-center gap-3">
<input
v-model.number="data.tcState.defaultClassId"
type="number"
min="1"
max="255"
class="w-32 rounded-lg border-2 border-gray-100 text-gray-500 focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
>
<span class="text-xs text-gray-400 dark:text-neutral-500">
{{ $t('admin.speed.defaultClassIdDesc') }}
</span>
</div>
</div>
</div>
<!-- Class Cards Grid -->
<div class="grid grid-cols-1 gap-4 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
<!-- Unassigned Clients -->
<div class="flex flex-col">
<div class="mb-3 rounded-lg bg-orange-50 px-4 py-2 text-center text-sm font-medium text-orange-800 dark:bg-orange-900/30 dark:text-orange-200">
{{ $t('admin.speed.unassigned') }}
</div>
<div class="flex-1 rounded-lg border-2 border-dashed border-orange-200 bg-orange-50/30 p-3 dark:border-orange-800 dark:bg-orange-950/20">
<div
v-for="client in unassignedClients"
:key="client.id"
class="mb-2 cursor-grab rounded-lg border border-orange-200 bg-white px-3 py-2 text-sm shadow-sm transition hover:shadow-md active:cursor-grabbing dark:border-orange-700 dark:bg-neutral-700"
draggable="true"
@dragstart="onDragStart(client, null)"
>
<div class="font-medium text-gray-800 dark:text-neutral-100">{{ client.name }}</div>
<div class="text-xs text-gray-500 dark:text-neutral-400">{{ client.ipv4Address }}</div>
</div>
<div v-if="unassignedClients.length === 0" class="text-center text-xs text-gray-400 dark:text-neutral-500 py-4">
{{ $t('admin.speed.noUnassigned') }}
</div>
</div>
</div>
<!-- Class Cards -->
<div
v-for="(cls, index) in data.tcState.classes"
:key="cls.id"
class="flex flex-col rounded-lg border border-gray-200 p-3 dark:border-neutral-600"
>
<div class="mb-3 flex items-center justify-between rounded-lg bg-blue-50 px-4 py-2 dark:bg-blue-900/30">
<span class="text-sm font-medium text-blue-800 dark:text-blue-200">
{{ cls.ulRate }} Mbit
</span>
<button
class="text-red-500 transition hover:text-red-700 dark:text-red-400"
:title="$t('admin.speed.removeClass')"
@click="removeClass(index)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div class="mb-2 flex items-center gap-2 px-1">
<label class="text-xs text-gray-500 dark:text-neutral-400">UL:</label>
<input
v-model.number="cls.ulRate"
type="number"
min="1"
class="w-full max-w-[5rem] rounded border border-gray-200 px-2 py-1 text-xs dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200"
@input="onClassUlChange(cls)"
>
<span class="text-xs text-gray-400">Mbit</span>
</div>
<div class="mb-2 px-1 text-xs text-gray-400 dark:text-neutral-500">
LS: {{ Math.floor(cls.ulRate / 2) }} Mbit
</div>
<div
class="flex-1 rounded-lg border-2 border-dashed border-blue-200 bg-blue-50/30 p-3 dark:border-blue-800 dark:bg-blue-950/20"
@dragover.prevent
@drop="onDrop(index)"
>
<div
v-for="client in getClientsForClass(cls)"
:key="client.ipv4Address"
class="mb-2 rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm shadow-sm transition hover:shadow-md dark:border-blue-700 dark:bg-neutral-700"
draggable="true"
@dragstart="onDragStart(client, index)"
>
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-gray-800 dark:text-neutral-100">{{ client.name }}</div>
<div class="text-xs text-gray-500 dark:text-neutral-400">{{ client.ipv4Address }}</div>
</div>
<button
class="text-gray-400 transition hover:text-red-500"
:title="$t('admin.speed.moveToUnassigned')"
@click="moveToUnassigned(client.ipv4Address, index)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div v-if="cls.clientIps.length === 0" class="text-center text-xs text-gray-400 dark:text-neutral-500 py-4">
{{ $t('admin.speed.dropHere') }}
</div>
</div>
</div>
<!-- Add Class Card -->
<div class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 p-6 dark:border-neutral-600">
<button
class="inline-flex items-center rounded bg-red-800 px-4 py-2 text-sm text-white transition hover:bg-red-600"
@click="addClass"
>
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
{{ $t('admin.speed.addClass') }}
</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
const { t } = useI18n();
const toast = useToast();
type TcClient = {
id: string | number;
name: string;
ipv4Address: string;
enabled: boolean;
};
type TcClass = {
id: number;
ulRate: number;
clientIps: string[];
};
type TcStateResponse = {
clients: TcClient[];
tcState: {
totalUlRate: number;
defaultClassId: number;
classes: TcClass[];
};
};
const { data: fetchData, refresh: _refresh } = await useFetch<TcStateResponse>('/api/tc/state', {
method: 'get',
});
// Use a ref instead of computed for stable reactivity
const data = ref<TcStateResponse | null>(null);
// Initialize from fetch data
watch(fetchData, (val) => {
if (val) {
data.value = JSON.parse(JSON.stringify(val));
}
}, { immediate: true });
// Computed: clients not assigned to any class
const unassignedClients = computed(() => {
if (!data.value) return [];
const assignedIps = new Set<string>();
for (const cls of data.value.tcState.classes) {
for (const ip of cls.clientIps) {
assignedIps.add(ip);
}
}
return data.value.clients.filter((c) => c.ipv4Address && !assignedIps.has(c.ipv4Address));
});
// Get clients for a given class
function getClientsForClass(cls: TcClass): TcClient[] {
if (!data.value) return [];
const ipSet = new Set(cls.clientIps);
return data.value.clients.filter((c) => c.ipv4Address && ipSet.has(c.ipv4Address));
}
// Drag state
const dragState = ref<{ client: TcClient; fromClassIdx: number | null } | null>(null);
function onDragStart(client: TcClient, fromClassIdx: number | null) {
dragState.value = { client, fromClassIdx };
}
function onDrop(toClassIdx: number) {
if (!dragState.value || !data.value) return;
const { client, fromClassIdx } = dragState.value;
// Remove IP from source
if (fromClassIdx !== null) {
const srcClass = data.value.tcState.classes[fromClassIdx];
if (srcClass) {
srcClass.clientIps = srcClass.clientIps.filter((ip) => ip !== client.ipv4Address);
}
}
// Add IP to target
const dstClass = data.value.tcState.classes[toClassIdx];
if (dstClass && client.ipv4Address && !dstClass.clientIps.includes(client.ipv4Address)) {
dstClass.clientIps.push(client.ipv4Address);
}
dragState.value = null;
}
function moveToUnassigned(ip: string, classIdx: number) {
if (!data.value) return;
const cls = data.value.tcState.classes[classIdx];
if (cls) {
cls.clientIps = cls.clientIps.filter((i) => i !== ip);
}
}
function onClassUlChange(cls: TcClass) {
// Auto-generate class ID as 1{UL} (e.g. UL=3 ID=13)
const ulStr = String(cls.ulRate);
const newId = parseInt('1' + ulStr, 10);
if (!isNaN(newId) && newId >= 10) {
// Only update if the new ID doesn't conflict with existing classes
const conflict = data.value?.tcState.classes.find((c) => c.id === newId && c !== cls);
if (!conflict) {
cls.id = newId;
}
}
}
function removeClass(index: number) {
if (!data.value) return;
data.value.tcState.classes.splice(index, 1);
}
function addClass() {
if (!data.value) return;
let newUl = 5;
const existingIds = new Set(data.value.tcState.classes.map((c) => c.id));
let newId: number;
do {
const s = String(newUl);
newId = parseInt('1' + s, 10);
if (existingIds.has(newId)) {
newUl++;
} else {
break;
}
} while (true);
data.value.tcState.classes.push({
id: newId,
ulRate: newUl,
clientIps: [],
});
// Re-sort by UL rate
data.value.tcState.classes.sort((a, b) => a.ulRate - b.ulRate);
}
async function refresh() {
await _refresh();
if (fetchData.value) {
data.value = JSON.parse(JSON.stringify(fetchData.value));
}
}
async function applyConfig() {
if (!data.value) return;
try {
const res = await $fetch('/api/tc/state', {
method: 'post',
body: data.value.tcState,
});
if (res.success) {
toast.showToast({
type: 'success',
message: res.message || t('admin.speed.applySuccess'),
});
} else {
toast.showToast({
type: 'error',
message: res.message || t('admin.speed.applyError'),
});
}
} catch (e: any) {
toast.showToast({
type: 'error',
message: e?.data?.message || e?.message || t('admin.speed.applyError'),
});
}
await refresh();
}
</script>

21
src/i18n/locales/en.json

@ -7,7 +7,8 @@
"general": "General",
"config": "Config",
"interface": "Interface",
"hooks": "Hooks"
"hooks": "Hooks",
"speed": "Speed"
}
},
"user": {
@ -157,6 +158,24 @@
"add": "Add"
},
"admin": {
"speed": {
"title": "Speed Regulation — HFSC Traffic Shaping",
"refresh": "Refresh",
"apply": "Apply Configuration",
"totalBandwidth": "Total Bandwidth (UL rate)",
"totalBandwidthDesc": "Root class 1:1 — max bandwidth",
"defaultClassId": "Default Class ID",
"defaultClassIdDesc": "Unmatched traffic goes to this class",
"unassigned": "Unassigned Clients",
"noUnassigned": "All clients are assigned",
"classLabel": "Class",
"removeClass": "Remove class",
"moveToUnassigned": "Move to unassigned",
"dropHere": "Drop clients here",
"addClass": "Add Class",
"applySuccess": "Traffic control configuration applied successfully",
"applyError": "Failed to apply traffic control configuration"
},
"general": {
"sessionTimeout": "Session Timeout",
"sessionTimeoutDesc": "Session duration for Remember Me (seconds)",

21
src/i18n/locales/ru.json

@ -7,7 +7,8 @@
"general": "Общие настройки",
"config": "Конфигурация",
"interface": "Сетевой интерфейс",
"hooks": "Хуки"
"hooks": "Хуки",
"speed": "Speed"
}
},
"user": {
@ -148,6 +149,24 @@
"add": "Добавить"
},
"admin": {
"speed": {
"title": "Speed Regulation — HFSC Traffic Shaping",
"refresh": "Обновить",
"apply": "Применить конфигурацию",
"totalBandwidth": "Общая пропускная способность (UL rate)",
"totalBandwidthDesc": "Root class 1:1 — максимальная пропускная способность",
"defaultClassId": "ID класса по умолчанию",
"defaultClassIdDesc": "Несопоставленный трафик идёт в этот класс",
"unassigned": "Клиенты без класса",
"noUnassigned": "Все клиенты распределены",
"classLabel": "Класс",
"removeClass": "Удалить класс",
"moveToUnassigned": "Переместить без класса",
"dropHere": "Перетащите клиентов сюда",
"addClass": "Добавить класс",
"applySuccess": "Конфигурация управления трафиком успешно применена",
"applyError": "Не удалось применить конфигурацию управления трафиком"
},
"general": {
"sessionTimeout": "Время жизни сессии",
"sessionTimeoutDesc": "Длительность сессии для «Запомнить меня» (в секундах)",

21
src/server/api/tc/state/index.get.ts

@ -0,0 +1,21 @@
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
export default definePermissionEventHandler(
'admin',
'any',
async () => {
const state = await Database.tcState.get();
const allClients = await Database.clients.getAllPublic({});
return {
clients: allClients.map((c) => ({
id: c.id,
name: c.name,
ipv4Address: c.ipv4Address,
enabled: c.enabled,
})),
tcState: state,
};
}
);

65
src/server/api/tc/state/index.post.ts

@ -0,0 +1,65 @@
import { readValidatedBody, createError } from 'h3';
import { containsCidr } from 'cidr-tools';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { TcStateUpdateSchema } from '#db/repositories/tcState/types';
import TCManager from '#server/utils/TCManager';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const body = await readValidatedBody(
event,
validateZod(TcStateUpdateSchema, event)
);
// Validate no IP is assigned to multiple classes
const allIps = body.classes.flatMap((c) => c.clientIps);
const uniqueIps = new Set(allIps);
if (uniqueIps.size !== allIps.length) {
throw createError({ statusCode: 400, statusMessage: 'An IP address cannot be assigned to multiple classes' });
}
// Validate class ID uniqueness
const classIds = body.classes.map((c) => c.id);
const uniqueIds = new Set(classIds);
if (uniqueIds.size !== classIds.length) {
throw createError({ statusCode: 400, statusMessage: 'Class IDs must be unique' });
}
// Validate IPs belong to WG subnet
const wgInterface = await Database.interfaces.get();
if (wgInterface.ipv4Cidr) {
for (const cls of body.classes) {
for (const ip of cls.clientIps) {
if (!containsCidr(wgInterface.ipv4Cidr, ip)) {
throw createError({
statusCode: 400,
statusMessage: `IP ${ip} is not within the WireGuard subnet ${wgInterface.ipv4Cidr}`,
});
}
}
}
}
// Validate class IDs are not root class (1:1)
if (body.defaultClassId === 1) {
throw createError({ statusCode: 400, statusMessage: 'Default class ID cannot be 1 (root class 1:1)' });
}
// Save state
await Database.tcState.update(body);
// Apply configuration
const result = await TCManager.applyConfig();
if (!result.success) {
return { success: false, message: result.message };
}
return { success: true, message: result.message };
}
);

8
src/server/database/migrations/0007_add_tc_state.sql

@ -0,0 +1,8 @@
CREATE TABLE `tc_state_table` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`total_ul_rate` integer NOT NULL DEFAULT 100,
`default_class_id` integer NOT NULL DEFAULT 11,
`classes` text NOT NULL DEFAULT '[]',
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);

69
src/server/database/migrations/meta/0007_snapshot.json

@ -0,0 +1,69 @@
{
"target": "sqlite",
"$schema": "./_journal.schema.json",
"version": "6",
"fragment": {
"tables": {
"tc_state_table": {
"name": "tc_state_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"default": 1,
"autoincrement": false
},
"total_ul_rate": {
"name": "total_ul_rate",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 100
},
"default_class_id": {
"name": "default_class_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 11
},
"classes": {
"name": "classes",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'[]'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "(CURRENT_TIMESTAMP)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"schemas": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}
}

7
src/server/database/migrations/meta/_journal.json

@ -50,6 +50,13 @@
"when": 1782122902196,
"tag": "0006_clear_leech",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1782200000000,
"tag": "0007_add_tc_state",
"breakpoints": true
}
]
}

26
src/server/database/repositories/tcState/schema.ts

@ -0,0 +1,26 @@
import { sql } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const tcState = sqliteTable('tc_state_table', {
id: int().primaryKey({ autoIncrement: false }).default(1),
totalUlRate: int('total_ul_rate').notNull().default(100),
defaultClassId: int('default_class_id').notNull().default(11),
/** JSON array of { id: number, ulRate: number, clientIps: string[] } */
classes: text({ mode: 'json' }).$type<TcClass[]>().notNull().default([]),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export type TcClass = {
id: number;
ulRate: number;
clientIps: string[];
};

76
src/server/database/repositories/tcState/service.ts

@ -0,0 +1,76 @@
import { sql, eq } from 'drizzle-orm';
import { tcState } from './schema';
import type { TcClass } from './schema';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
return {
get: db.query.tcState
.findFirst({ where: eq(tcState.id, 1) })
.prepare(),
};
}
export class TcStateService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async get(): Promise<{
totalUlRate: number;
defaultClassId: number;
classes: TcClass[];
}> {
let state = await this.#statements.get.execute();
if (!state) {
// First run: create default state with empty classes and default class 1:11
await this.#db.insert(tcState).values({
id: 1,
totalUlRate: 100,
defaultClassId: 11,
classes: [],
}).execute();
state = await this.#statements.get.execute();
if (!state) {
throw new Error('Failed to create TC state');
}
}
return {
totalUlRate: state.totalUlRate,
defaultClassId: state.defaultClassId,
classes: state.classes as TcClass[],
};
}
async update(data: {
totalUlRate: number;
defaultClassId: number;
classes: TcClass[];
}) {
// Classes are sorted by ulRate ascending
const sortedClasses = [...data.classes].sort((a, b) => a.ulRate - b.ulRate);
await this.#db
.insert(tcState)
.values({
id: 1,
totalUlRate: data.totalUlRate,
defaultClassId: data.defaultClassId,
classes: sortedClasses,
})
.onConflictDoUpdate({
target: tcState.id,
set: {
totalUlRate: data.totalUlRate,
defaultClassId: data.defaultClassId,
classes: sortedClasses,
},
})
.execute();
}
}

27
src/server/database/repositories/tcState/types.ts

@ -0,0 +1,27 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { tcState, TcClass } from './schema';
import { schemaForType, t } from '#server/utils/types';
export type TcStateType = InferSelectModel<typeof tcState>;
export const TcClassSchema = z.object({
id: z.number().int().min(10),
ulRate: z.number().int().min(1).max(10000),
clientIps: z.array(
z.string().min(1)
),
});
export const TcStateUpdateSchema = schemaForType<{
totalUlRate: number;
defaultClassId: number;
classes: TcClass[];
}>()(
z.object({
totalUlRate: z.number().int().min(1).max(10000),
defaultClassId: z.number().int().min(1),
classes: z.array(TcClassSchema),
})
);

1
src/server/database/schema.ts

@ -4,5 +4,6 @@ export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';
export * from './repositories/oneTimeLink/schema';
export * from './repositories/tcState/schema';
export * from './repositories/user/schema';
export * from './repositories/userConfig/schema';

4
src/server/database/sqlite.ts

@ -4,6 +4,7 @@ import { createClient } from '@libsql/client';
import { createDebug } from 'obug';
import { eq } from 'drizzle-orm';
import { TcStateService } from '#db/repositories/tcState/service';
import { GeneralService } from '#db/repositories/general/service';
import { UserService } from '#db/repositories/user/service';
import { UserConfigService } from '#db/repositories/userConfig/service';
@ -12,6 +13,7 @@ import { HooksService } from '#db/repositories/hooks/service';
import { OneTimeLinkService } from '#db/repositories/oneTimeLink/service';
import { ClientService } from '#db/repositories/client/service';
import * as schema from '#db/schema';
import { tcState } from '#db/repositories/tcState/schema';
import { WG_ENV, WG_INITIAL_ENV } from '#server/utils/config';
const DB_DEBUG = createDebug('Database');
@ -43,6 +45,7 @@ class DBService {
interfaces: InterfaceService;
hooks: HooksService;
oneTimeLinks: OneTimeLinkService;
tcState: TcStateService;
constructor(db: DBType) {
this.clients = new ClientService(db);
@ -52,6 +55,7 @@ class DBService {
this.interfaces = new InterfaceService(db);
this.hooks = new HooksService(db);
this.oneTimeLinks = new OneTimeLinkService(db);
this.tcState = new TcStateService(db);
}
}

192
src/server/utils/TCManager.ts

@ -0,0 +1,192 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { createDebug } from 'obug';
import Database from '#server/utils/Database';
import type { TcClass } from '#db/repositories/tcState/schema';
const TC_DEBUG = createDebug('TCManager');
const execAsync = promisify(exec);
/**
* TCManager - Traffic Control Manager for WireGuard
*
* Applies HFSC bandwidth limits using Linux tc.
*
* Hierarchy:
* 1: (root qdisc, default defaultClassId)
* 1:1 (root class, sc rate = totalUlRate, ul rate = totalUlRate)
* 1:{classId} (user classes, ls rate = ulRate/2, ul rate = ulRate)
*
*
* Command sequence:
* 1. tc qdisc del dev {iface} root
* 2. tc qdisc add dev {iface} parent root handle 1: hfsc default {defaultClassId}
* 3. tc class add dev {iface} parent 1: classid 1:1 hfsc sc rate {totalUlRate}mbit ul rate {totalUlRate}mbit
* 4. tc class add dev {iface} parent 1:1 classid 1:{classId} hfsc ls rate {lsRate}mbit ul rate {ulRate}mbit
* 5. tc filter add dev {iface} parent 1: protocol ip prio 1 u32 match ip dst {ip} classid 1:{classId}
*
* Class ID convention: 100 + ulRate (e.g. ulRate=12 classId=112)
* No ip6, no src filters.
*/
class TCManager {
private device: string = 'wg0';
/**
* Apply tc configuration from DB state
*/
async applyConfig(): Promise<{ success: boolean; message: string }> {
const errors: string[] = [];
const warnings: string[] = [];
try {
// Get interface name
try {
const wgInterface = await Database.interfaces.get();
this.device = wgInterface.name;
} catch {
TC_DEBUG('Using default device: wg0');
this.device = 'wg0';
}
// Get TC state from DB
const state = await Database.tcState.get();
TC_DEBUG(`Starting tc configuration apply on ${this.device}...`);
TC_DEBUG(`Default class: 1:${state.defaultClassId}, ${state.classes.length} user classes`);
// Step 1: Full cleanup - remove existing rules
TC_DEBUG('Removing existing tc rules...');
try {
await execAsync(`tc qdisc del dev ${this.device} root`, { timeout: 10000 });
} catch (err: any) {
const errMsg = (err?.stderr || err?.message || '').toLowerCase();
if (
!errMsg.includes('no such file or directory') &&
!errMsg.includes('handle of zero') &&
!errMsg.includes('cannot find')
) {
errors.push(`Error removing existing rules: ${errMsg}`);
} else {
TC_DEBUG('No existing rules to remove (ignored)');
}
}
// Step 2: Create root qdisc with default class
TC_DEBUG(`Creating root qdisc (default ${state.defaultClassId})...`);
try {
await execAsync(
`tc qdisc add dev ${this.device} parent root handle 1: hfsc default ${state.defaultClassId}`,
{ timeout: 10000 }
);
} catch (err: any) {
errors.push(`Error creating root qdisc: ${err?.stderr || err?.message}`);
}
// Step 3: Create root class 1:1 at total channel bandwidth
const totalUlRate = state.totalUlRate;
TC_DEBUG(`Creating root class 1:1 at ${totalUlRate}mbit...`);
try {
await execAsync(
`tc class add dev ${this.device} parent 1: classid 1:1 hfsc sc rate ${totalUlRate}mbit ul rate ${totalUlRate}mbit`,
{ timeout: 10000 }
);
} catch (err: any) {
const errMsg = (err?.stderr || err?.message || '').toLowerCase();
if (!errMsg.includes('exists')) {
errors.push(`Error creating root class: ${errMsg}`);
} else {
warnings.push('Root class already exists');
}
}
// Step 4: Create user-defined classes sorted by ulRate
const sortedClasses = [...state.classes].sort((a, b) => a.ulRate - b.ulRate);
TC_DEBUG(`Creating ${sortedClasses.length} user classes...`);
for (const tcClass of sortedClasses) {
const classId = tcClass.id;
const ul = tcClass.ulRate;
const ls = Math.floor(ul / 2) || 1;
try {
await execAsync(
`tc class add dev ${this.device} parent 1:1 classid 1:${classId} hfsc ls rate ${ls}mbit ul rate ${ul}mbit`,
{ timeout: 10000 }
);
TC_DEBUG(`Class 1:${classId} created (ul=${ul}, ls=${ls})`);
} catch (err: any) {
const errMsg = (err?.stderr || err?.message || '').toLowerCase();
if (!errMsg.includes('exists')) {
errors.push(`Error creating class 1:${classId}: ${errMsg}`);
} else {
warnings.push(`Class 1:${classId} already exists`);
}
}
}
// Step 5: Create dst-only filters for each client IP
for (const tcClass of sortedClasses) {
for (const ip of tcClass.clientIps) {
try {
await execAsync(
`tc filter add dev ${this.device} parent 1: protocol ip prio 1 u32 match ip dst ${ip} classid 1:${tcClass.id}`,
{ timeout: 10000 }
);
TC_DEBUG(`Filter added: dst ${ip} → class 1:${tcClass.id}`);
} catch (err: any) {
const errMsg = (err?.stderr || err?.message || '').toLowerCase();
if (!errMsg.includes('exists')) {
errors.push(`Error adding filter for ${ip}: ${errMsg}`);
} else {
warnings.push(`Filter for ${ip} already exists`);
}
}
}
}
if (errors.length > 0) {
TC_DEBUG(`Configuration applied with ${errors.length} errors`);
return {
success: false,
message: `Configuration applied with errors:\n${errors.join('\n')}${
warnings.length > 0 ? `\n\nWarnings:\n${warnings.join('\n')}` : ''
}`,
};
}
if (warnings.length > 0) {
TC_DEBUG(`Configuration applied with ${warnings.length} warnings`);
return {
success: true,
message: `Configuration applied with warnings:\n${warnings.join('\n')}`,
};
}
TC_DEBUG('Configuration applied successfully');
return { success: true, message: 'Traffic control configuration applied successfully' };
} catch (err: any) {
TC_DEBUG(`Critical error: ${err?.message}`);
return {
success: false,
message: `Critical error applying configuration: ${err?.message}`,
};
}
}
/**
* Remove all tc rules from the WireGuard interface
*/
async cleanConfig(): Promise<void> {
try {
await execAsync(`tc qdisc del dev ${this.device} root`, { timeout: 10000 });
TC_DEBUG('tc rules cleaned');
} catch {
TC_DEBUG('No tc rules to clean');
}
}
}
export default new TCManager();

14
src/server/utils/WireGuard.ts

@ -6,6 +6,7 @@ import Database from '#server/utils/Database';
import { OLD_ENV, WG_ENV } from '#server/utils/config';
import { firewall } from '#server/utils/firewall';
import { encodeQRCode } from '#server/utils/qr';
import TCManager from '#server/utils/TCManager';
import type { ID } from '#server/utils/types';
import { wg } from '#server/utils/wgHelper';
import { setIntervalImmediately } from '#shared/utils/time';
@ -265,6 +266,19 @@ class WireGuard {
await this.#applyFirewallRules(wgInterface);
WG_DEBUG('Firewall rules applied successfully.');
// Apply TC (traffic control) configuration
WG_DEBUG('Applying TC configuration...');
try {
const tcResult = await TCManager.applyConfig();
if (tcResult.success) {
WG_DEBUG('TC configuration applied successfully.');
} else {
WG_DEBUG(`TC configuration applied with warnings/errors: ${tcResult.message}`);
}
} catch (tcErr) {
WG_DEBUG(`Failed to apply TC configuration: ${tcErr}`);
}
WG_DEBUG('Starting Cron Job...');
await this.startCronJob();
WG_DEBUG('Cron Job started successfully.');

Loading…
Cancel
Save