mirror of https://github.com/wg-easy/wg-easy
16 changed files with 895 additions and 2 deletions
@ -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> |
|||
@ -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, |
|||
}; |
|||
} |
|||
); |
|||
@ -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 }; |
|||
} |
|||
); |
|||
@ -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 |
|||
); |
|||
@ -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": {} |
|||
} |
|||
} |
|||
} |
|||
@ -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[]; |
|||
}; |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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), |
|||
}) |
|||
); |
|||
@ -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(); |
|||
Loading…
Reference in new issue