From f009be7f3e90753148feaffb65ee2ca170917f55 Mon Sep 17 00:00:00 2001 From: Ha Vu Date: Thu, 1 Jan 2026 12:34:07 +0700 Subject: [PATCH] feat: add global bandwidth limiting for WireGuard VPN traffic Add ability to limit download/upload speeds for all VPN clients using Linux Traffic Control (tc) with HTB qdisc. Features: - Admin panel UI to enable/disable and configure bandwidth limits - Download limiting via tc on WireGuard interface egress - Upload limiting via IFB device mirroring ingress traffic - Auto-detection of IFB kernel module availability - Auto-restart interface when bandwidth settings change Technical: - New database migration for bandwidth settings - tc commands injected as PostUp/PostDown hooks - API endpoints: GET/POST /api/admin/general, GET /api/admin/bandwidth-status i18n: Add translations for 16 languages docs: Add bandwidth limiting setup guide --- .gitignore | 6 +- Dockerfile | 3 +- Dockerfile.dev | 3 +- docs/content/guides/bandwidth-limiting.md | 122 ++ src/app/pages/admin/general.vue | 40 + src/i18n/locales/bn.json | 14 +- src/i18n/locales/de.json | 14 +- src/i18n/locales/en.json | 14 +- src/i18n/locales/es.json | 14 +- src/i18n/locales/fr.json | 14 +- src/i18n/locales/id.json | 14 +- src/i18n/locales/it.json | 14 +- src/i18n/locales/ko.json | 14 +- src/i18n/locales/pl.json | 14 +- src/i18n/locales/pt-BR.json | 14 +- src/i18n/locales/ru.json | 14 +- src/i18n/locales/tr.json | 14 +- src/i18n/locales/uk.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/i18n/locales/zh-HK.json | 14 +- src/i18n/locales/zh-TW.json | 14 +- src/server/api/admin/bandwidth-status.get.ts | 15 + src/server/api/admin/general.post.ts | 15 + .../migrations/0003_bandwidth_limit.sql | 3 + .../migrations/meta/0003_snapshot.json | 1000 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../database/repositories/general/schema.ts | 6 + .../database/repositories/general/service.ts | 22 + .../database/repositories/general/types.ts | 10 + src/server/utils/WireGuard.ts | 23 + src/server/utils/bandwidth.ts | 116 ++ src/server/utils/wgHelper.ts | 47 +- 32 files changed, 1623 insertions(+), 39 deletions(-) create mode 100644 docs/content/guides/bandwidth-limiting.md create mode 100644 src/server/api/admin/bandwidth-status.get.ts create mode 100644 src/server/database/migrations/0003_bandwidth_limit.sql create mode 100644 src/server/database/migrations/meta/0003_snapshot.json create mode 100644 src/server/utils/bandwidth.ts diff --git a/.gitignore b/.gitignore index c36465bd..f611facb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .DS_Store *.swp -node_modules \ No newline at end of file +node_modules +# Claude Code local settings +.claude/ +test_output.txt +repomix-output.xml diff --git a/Dockerfile b/Dockerfile index 45b46d50..a40b3ee2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,8 @@ RUN apk add --no-cache \ nftables \ kmod \ iptables-legacy \ - wireguard-tools + wireguard-tools \ + iproute2 RUN mkdir -p /etc/amnezia RUN ln -s /etc/wireguard /etc/amnezia/amneziawg diff --git a/Dockerfile.dev b/Dockerfile.dev index e9dcec16..220d3a9a 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -16,7 +16,8 @@ RUN apk add --no-cache \ ip6tables \ kmod \ iptables-legacy \ - wireguard-tools + wireguard-tools \ + iproute2 # Use iptables-legacy RUN update-alternatives --install /usr/sbin/iptables iptables /usr/sbin/iptables-legacy 10 --slave /usr/sbin/iptables-restore iptables-restore /usr/sbin/iptables-legacy-restore --slave /usr/sbin/iptables-save iptables-save /usr/sbin/iptables-legacy-save diff --git a/docs/content/guides/bandwidth-limiting.md b/docs/content/guides/bandwidth-limiting.md new file mode 100644 index 00000000..a09f5d5b --- /dev/null +++ b/docs/content/guides/bandwidth-limiting.md @@ -0,0 +1,122 @@ +--- +title: Bandwidth Limiting +--- + +WG-Easy supports global bandwidth limiting for all VPN traffic using Linux Traffic Control (tc). + +## How It Works + +Bandwidth limiting uses Linux Traffic Control (`tc`) with HTB (Hierarchical Token Bucket): + +- **Download limiting**: Applied directly to WireGuard interface egress +- **Upload limiting**: Uses IFB (Intermediate Functional Block) device to mirror ingress traffic + +The tc commands are injected as `PostUp`/`PostDown` hooks in the WireGuard configuration. + +## Setup Guide + +### 1. Access Admin Panel + +Navigate to **Admin Panel** → **General** section. + +### 2. Enable Bandwidth Limiting + +Toggle **"Enable Bandwidth Limiting"** switch to ON. + +### 3. Configure Limits + +| Setting | Description | Range | +|---------|-------------|-------| +| **Download Limit (Mbps)** | Max download speed for clients | 1-10000 (0 = unlimited) | +| **Upload Limit (Mbps)** | Max upload speed from clients | 1-10000 (0 = unlimited) | + +### 4. Save Settings + +Click **Save**. WireGuard interface will automatically restart to apply changes. + +## Requirements + +### Download Limiting +- Works out of the box on all Linux systems + +### Upload Limiting +Requires the `ifb` (Intermediate Functional Block) kernel module: + +```bash +# Check if IFB is available +modprobe ifb +lsmod | grep ifb +``` + +If IFB is not available: +- Download limiting will still work +- Upload limiting will be skipped +- A warning will display in the admin panel + +### Docker Container +The container must run with: +- `--cap-add=NET_ADMIN` (required for tc commands) +- `--cap-add=SYS_MODULE` (required for IFB module loading) + +Example docker-compose: +```yaml +services: + wg-easy: + image: ghcr.io/wg-easy/wg-easy + cap_add: + - NET_ADMIN + - SYS_MODULE + # ... other settings +``` + +## Technical Details + +### TC Commands Generated + +**Download (egress on wg0):** +```bash +tc qdisc add dev wg0 root handle 1: htb default 10 +tc class add dev wg0 parent 1: classid 1:1 htb rate 100mbit ceil 100mbit +tc class add dev wg0 parent 1:1 classid 1:10 htb rate 100mbit ceil 100mbit +``` + +**Upload (ingress via IFB mirror):** +```bash +ip link add ifb0 type ifb +ip link set ifb0 up +tc qdisc add dev wg0 handle ffff: ingress +tc filter add dev wg0 parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev ifb0 +tc qdisc add dev ifb0 root handle 1: htb default 10 +tc class add dev ifb0 parent 1: classid 1:1 htb rate 50mbit ceil 50mbit +tc class add dev ifb0 parent 1:1 classid 1:10 htb rate 50mbit ceil 50mbit +``` + +### Database Schema + +Settings stored in `general_table`: +- `bandwidth_enabled` (boolean) +- `download_limit_mbps` (integer) +- `upload_limit_mbps` (integer) + +### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/admin/general` | Get current settings | +| POST | `/api/admin/general` | Update settings (triggers restart) | +| GET | `/api/admin/bandwidth-status` | Check IFB availability | + +## Troubleshooting + +### Bandwidth limits not applied +1. Check if container has `NET_ADMIN` capability +2. Verify WireGuard interface is running: `wg show` +3. Check tc rules: `tc qdisc show dev wg0` + +### Upload limiting not working +1. Check IFB module: `lsmod | grep ifb` +2. Ensure container has `SYS_MODULE` capability +3. Some cloud VPS may not support IFB kernel module + +### Settings not saving +Check browser console and server logs for API errors. diff --git a/src/app/pages/admin/general.vue b/src/app/pages/admin/general.vue index 9515e456..0ede2fc0 100644 --- a/src/app/pages/admin/general.vue +++ b/src/app/pages/admin/general.vue @@ -30,6 +30,42 @@ :description="$t('admin.general.jsonDesc')" /> + + {{ $t('admin.general.bandwidth') }} + + + +
+

+ {{ $t('admin.general.ifbWarning') }} +

+
+
{{ $t('form.actions') }} @@ -45,6 +81,10 @@ const { data: _data, refresh } = await useFetch(`/api/admin/general`, { }); const data = toRef(_data.value); +const { data: bandwidthStatus } = await useFetch(`/api/admin/bandwidth-status`, { + method: 'get', +}); + const _submit = useSubmit( `/api/admin/general`, { diff --git a/src/i18n/locales/bn.json b/src/i18n/locales/bn.json index e8868342..acd6f15c 100644 --- a/src/i18n/locales/bn.json +++ b/src/i18n/locales/bn.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "JSON ফরম্যাটে মেট্রিক্সের রুট", "prometheus": "Prometheus", - "prometheusDesc": "Prometheus মেট্রিক্সের রুট" + "prometheusDesc": "Prometheus মেট্রিক্সের রুট", + "bandwidth": "ব্যান্ডউইথ সীমাবদ্ধকরণ", + "bandwidthEnabled": "ব্যান্ডউইথ সীমাবদ্ধকরণ সক্রিয় করুন", + "bandwidthEnabledDesc": "সমস্ত VPN ট্রাফিকে গ্লোবাল ব্যান্ডউইথ সীমা প্রয়োগ করুন", + "downloadLimit": "ডাউনলোড সীমা (Mbps)", + "downloadLimitDesc": "ক্লায়েন্টদের জন্য সর্বোচ্চ ডাউনলোড গতি (0 = সীমাহীন)", + "uploadLimit": "আপলোড সীমা (Mbps)", + "uploadLimitDesc": "ক্লায়েন্টদের থেকে সর্বোচ্চ আপলোড গতি (0 = সীমাহীন)", + "ifbWarning": "আপলোড সীমাবদ্ধকরণের জন্য IFB kernel module প্রয়োজন, যা এই হোস্টে উপলব্ধ নয়" }, "config": { "connection": "সংযোগ", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "সেশন টাইমআউট", "metricsEnabled": "মেট্রিক্স", - "metricsPassword": "মেট্রিক্স পাসওয়ার্ড" + "metricsPassword": "মেট্রিক্স পাসওয়ার্ড", + "bandwidthEnabled": "ব্যান্ডউইথ সীমাবদ্ধকরণ", + "bandwidthLimit": "ব্যান্ডউইথ সীমা" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f1abbba2..53d1fa2e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -145,7 +145,15 @@ "json": "JSON", "jsonDesc": "Pfad zu den Statistiken als JSON", "prometheus": "Prometheus", - "prometheusDesc": "Pfad zu den Prometheus-Statistiken" + "prometheusDesc": "Pfad zu den Prometheus-Statistiken", + "bandwidth": "Bandbreitenbegrenzung", + "bandwidthEnabled": "Bandbreitenbegrenzung aktivieren", + "bandwidthEnabledDesc": "Globale Bandbreitenlimits auf den gesamten VPN-Datenverkehr anwenden", + "downloadLimit": "Download-Limit (Mbps)", + "downloadLimitDesc": "Maximale Download-Geschwindigkeit für Clients (0 = unbegrenzt)", + "uploadLimit": "Upload-Limit (Mbps)", + "uploadLimitDesc": "Maximale Upload-Geschwindigkeit von Clients (0 = unbegrenzt)", + "ifbWarning": "Upload-Begrenzung erfordert das IFB-Kernelmodul, das auf diesem Host nicht verfügbar ist" }, "config": { "connection": "Verbindung", @@ -208,7 +216,9 @@ "general": { "sessionTimeout": "Sitzungszeitüberschreitung", "metricsEnabled": "Statistiken", - "metricsPassword": "Passwort für Statistiken" + "metricsPassword": "Passwort für Statistiken", + "bandwidthEnabled": "Bandbreitenbegrenzung", + "bandwidthLimit": "Bandbreitenlimit" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 17fc3f7f..be57ddd7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -152,7 +152,15 @@ "json": "JSON", "jsonDesc": "Route for metrics in JSON format", "prometheus": "Prometheus", - "prometheusDesc": "Route for Prometheus metrics" + "prometheusDesc": "Route for Prometheus metrics", + "bandwidth": "Bandwidth Limiting", + "bandwidthEnabled": "Enable Bandwidth Limiting", + "bandwidthEnabledDesc": "Apply global bandwidth limits to all VPN traffic", + "downloadLimit": "Download Limit (Mbps)", + "downloadLimitDesc": "Maximum download speed for clients (0 = unlimited)", + "uploadLimit": "Upload Limit (Mbps)", + "uploadLimitDesc": "Maximum upload speed from clients (0 = unlimited)", + "ifbWarning": "Upload limiting requires IFB kernel module which is not available on this host" }, "config": { "connection": "Connection", @@ -215,7 +223,9 @@ "general": { "sessionTimeout": "Session Timeout", "metricsEnabled": "Metrics", - "metricsPassword": "Metrics Password" + "metricsPassword": "Metrics Password", + "bandwidthEnabled": "Bandwidth Enabled", + "bandwidthLimit": "Bandwidth Limit" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 074eaf89..aa84d6e6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "Ruta para métricas en formato JSON", "prometheus": "Prometheus", - "prometheusDesc": "Ruta para métricas de Prometheus" + "prometheusDesc": "Ruta para métricas de Prometheus", + "bandwidth": "Limitación de ancho de banda", + "bandwidthEnabled": "Habilitar limitación de ancho de banda", + "bandwidthEnabledDesc": "Aplicar límites globales de ancho de banda a todo el tráfico VPN", + "downloadLimit": "Límite de descarga (Mbps)", + "downloadLimitDesc": "Velocidad máxima de descarga para clientes (0 = ilimitado)", + "uploadLimit": "Límite de subida (Mbps)", + "uploadLimitDesc": "Velocidad máxima de subida desde clientes (0 = ilimitado)", + "ifbWarning": "La limitación de subida requiere el módulo del kernel IFB, que no está disponible en este host" }, "config": { "connection": "Conexión", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "Tiempo de sesión", "metricsEnabled": "Métricas", - "metricsPassword": "Contraseña de métricas" + "metricsPassword": "Contraseña de métricas", + "bandwidthEnabled": "Limitación de ancho de banda", + "bandwidthLimit": "Límite de ancho de banda" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 18a0310c..1702be5a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -152,7 +152,15 @@ "json": "JSON", "jsonDesc": "Route pour les métriques au format JSON", "prometheus": "Prometheus", - "prometheusDesc": "Route pour les métriques Prometheus" + "prometheusDesc": "Route pour les métriques Prometheus", + "bandwidth": "Limitation de bande passante", + "bandwidthEnabled": "Activer la limitation de bande passante", + "bandwidthEnabledDesc": "Appliquer des limites globales de bande passante à tout le trafic VPN", + "downloadLimit": "Limite de téléchargement (Mbps)", + "downloadLimitDesc": "Vitesse de téléchargement maximale pour les clients (0 = illimité)", + "uploadLimit": "Limite d’envoi (Mbps)", + "uploadLimitDesc": "Vitesse d’envoi maximale depuis les clients (0 = illimité)", + "ifbWarning": "La limitation d’envoi nécessite le module kernel IFB, non disponible sur cet hôte" }, "config": { "connection": "Connexion", @@ -215,7 +223,9 @@ "general": { "sessionTimeout": "Délai d'expiration de la session", "metricsEnabled": "Métriques", - "metricsPassword": "Mot de passe métriques" + "metricsPassword": "Mot de passe métriques", + "bandwidthEnabled": "Limitation de bande passante", + "bandwidthLimit": "Limite de bande passante" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index bda9ad64..e2bbcc57 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "Rute untuk metrik dalam format JSON", "prometheus": "Prometheus", - "prometheusDesc": "Rute untuk metrik Prometheus" + "prometheusDesc": "Rute untuk metrik Prometheus", + "bandwidth": "Pembatasan Bandwidth", + "bandwidthEnabled": "Aktifkan Pembatasan Bandwidth", + "bandwidthEnabledDesc": "Terapkan batas bandwidth global ke semua lalu lintas VPN", + "downloadLimit": "Batas Unduhan (Mbps)", + "downloadLimitDesc": "Kecepatan unduhan maksimum untuk klien (0 = tidak terbatas)", + "uploadLimit": "Batas Unggahan (Mbps)", + "uploadLimitDesc": "Kecepatan unggahan maksimum dari klien (0 = tidak terbatas)", + "ifbWarning": "Pembatasan unggahan memerlukan modul kernel IFB yang tidak tersedia di host ini" }, "config": { "connection": "Koneksi", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "Waktu Sesi Habis", "metricsEnabled": "Metrik", - "metricsPassword": "Kata Sandi Metrik" + "metricsPassword": "Kata Sandi Metrik", + "bandwidthEnabled": "Pembatasan Bandwidth", + "bandwidthLimit": "Batas Bandwidth" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bf70a590..50e3a599 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "Percorso per metriche in formato JSON", "prometheus": "Prometheus", - "prometheusDesc": "Percorso per metriche Prometheus" + "prometheusDesc": "Percorso per metriche Prometheus", + "bandwidth": "Limitazione della larghezza di banda", + "bandwidthEnabled": "Abilita limitazione della larghezza di banda", + "bandwidthEnabledDesc": "Applica limiti globali di larghezza di banda a tutto il traffico VPN", + "downloadLimit": "Limite di download (Mbps)", + "downloadLimitDesc": "Velocità massima di download per i client (0 = illimitato)", + "uploadLimit": "Limite di upload (Mbps)", + "uploadLimitDesc": "Velocità massima di upload dai client (0 = illimitato)", + "ifbWarning": "La limitazione dell'upload richiede il modulo kernel IFB, non disponibile su questo host" }, "config": { "connection": "Connessione", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "Timeout Sessione", "metricsEnabled": "Metriche", - "metricsPassword": "Password Metriche" + "metricsPassword": "Password Metriche", + "bandwidthEnabled": "Limitazione larghezza di banda", + "bandwidthLimit": "Limite larghezza di banda" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index e712aa3c..681d2758 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "JSON 형식의 메트릭 경로", "prometheus": "프로메테우스", - "prometheusDesc": "Prometheus 메트릭을 위한 경로" + "prometheusDesc": "Prometheus 메트릭을 위한 경로", + "bandwidth": "대역폭 제한", + "bandwidthEnabled": "대역폭 제한 활성화", + "bandwidthEnabledDesc": "모든 VPN 트래픽에 전역 대역폭 제한 적용", + "downloadLimit": "다운로드 제한 (Mbps)", + "downloadLimitDesc": "클라이언트의 최대 다운로드 속도 (0 = 무제한)", + "uploadLimit": "업로드 제한 (Mbps)", + "uploadLimitDesc": "클라이언트의 최대 업로드 속도 (0 = 무제한)", + "ifbWarning": "업로드 제한은 IFB 커널 모듈이 필요하지만 이 호스트에서 사용할 수 없습니다" }, "config": { "connection": "연결", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "세션 타임아웃", "metricsEnabled": "메트릭", - "metricsPassword": "메트릭 비밀번호" + "metricsPassword": "메트릭 비밀번호", + "bandwidthEnabled": "대역폭 제한 활성화", + "bandwidthLimit": "대역폭 제한" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 85f11627..8720727d 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "Ścieżka dla metryk w formacie JSON", "prometheus": "Prometheus", - "prometheusDesc": "Ścieżka dla metryk Prometheus" + "prometheusDesc": "Ścieżka dla metryk Prometheus", + "bandwidth": "Ograniczanie przepustowości", + "bandwidthEnabled": "Włącz ograniczanie przepustowości", + "bandwidthEnabledDesc": "Zastosuj globalne limity przepustowości do całego ruchu VPN", + "downloadLimit": "Limit pobierania (Mbps)", + "downloadLimitDesc": "Maksymalna prędkość pobierania dla klientów (0 = bez limitu)", + "uploadLimit": "Limit wysyłania (Mbps)", + "uploadLimitDesc": "Maksymalna prędkość wysyłania od klientów (0 = bez limitu)", + "ifbWarning": "Ograniczanie wysyłania wymaga modułu jądra IFB, który nie jest dostępny na tym hoście" }, "config": { "connection": "Połączenie", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "Limit czasu sesji", "metricsEnabled": "Metryki", - "metricsPassword": "Hasło do metryk" + "metricsPassword": "Hasło do metryk", + "bandwidthEnabled": "Ograniczanie przepustowości", + "bandwidthLimit": "Limit przepustowości" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index d99a4e29..4e95d500 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "Rota para métricas em formato JSON", "prometheus": "Prometheus", - "prometheusDesc": "Rota para métricas do Prometheus" + "prometheusDesc": "Rota para métricas do Prometheus", + "bandwidth": "Limitação de largura de banda", + "bandwidthEnabled": "Ativar limitação de largura de banda", + "bandwidthEnabledDesc": "Aplicar limites globais de largura de banda a todo o tráfego VPN", + "downloadLimit": "Limite de download (Mbps)", + "downloadLimitDesc": "Velocidade máxima de download para clientes (0 = ilimitado)", + "uploadLimit": "Limite de upload (Mbps)", + "uploadLimitDesc": "Velocidade máxima de upload dos clientes (0 = ilimitado)", + "ifbWarning": "A limitação de upload requer o módulo de kernel IFB, que não está disponível neste host" }, "config": { "connection": "Conexão", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "Tempo limite da sessão", "metricsEnabled": "Métricas", - "metricsPassword": "Senha de métricas" + "metricsPassword": "Senha de métricas", + "bandwidthEnabled": "Limitação de largura de banda", + "bandwidthLimit": "Limite de largura de banda" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 384b655e..8d92e131 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -152,7 +152,15 @@ "json": "JSON", "jsonDesc": "Путь для метрик в формате JSON", "prometheus": "Prometheus", - "prometheusDesc": "Путь для метрик Prometheus" + "prometheusDesc": "Путь для метрик Prometheus", + "bandwidth": "Ограничение пропускной способности", + "bandwidthEnabled": "Включить ограничение пропускной способности", + "bandwidthEnabledDesc": "Применить глобальные ограничения пропускной способности ко всему VPN-трафику", + "downloadLimit": "Лимит загрузки (Mbps)", + "downloadLimitDesc": "Максимальная скорость загрузки для клиентов (0 = без ограничений)", + "uploadLimit": "Лимит выгрузки (Mbps)", + "uploadLimitDesc": "Максимальная скорость выгрузки от клиентов (0 = без ограничений)", + "ifbWarning": "Ограничение выгрузки требует модуль ядра IFB, который недоступен на этом хосте" }, "config": { "connection": "Соединение", @@ -215,7 +223,9 @@ "general": { "sessionTimeout": "Время жизни сессии", "metricsEnabled": "Метрики", - "metricsPassword": "Пароль для метрик" + "metricsPassword": "Пароль для метрик", + "bandwidthEnabled": "Ограничение пропускной способности", + "bandwidthLimit": "Лимит пропускной способности" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 866ad487..e672c9c9 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -148,7 +148,15 @@ "json": "JSON", "jsonDesc": "JSON formatında metrikler için rota", "prometheus": "Prometheus", - "prometheusDesc": "Prometheus metrikleri için rota" + "prometheusDesc": "Prometheus metrikleri için rota", + "bandwidth": "Bant Genişliği Sınırlaması", + "bandwidthEnabled": "Bant Genişliği Sınırlamasını Etkinleştir", + "bandwidthEnabledDesc": "Tüm VPN trafiğine global bant genişliği limitleri uygula", + "downloadLimit": "İndirme Limiti (Mbps)", + "downloadLimitDesc": "İstemciler için maksimum indirme hızı (0 = sınırsız)", + "uploadLimit": "Yükleme Limiti (Mbps)", + "uploadLimitDesc": "İstemcilerden maksimum yükleme hızı (0 = sınırsız)", + "ifbWarning": "Yükleme sınırlaması IFB çekirdek modülü gerektirir ve bu ana makinede mevcut değildir" }, "config": { "connection": "Bağlantı", @@ -211,7 +219,9 @@ "general": { "sessionTimeout": "Oturum Zaman Aşımı", "metricsEnabled": "Metrikler", - "metricsPassword": "Metrik Şifresi" + "metricsPassword": "Metrik Şifresi", + "bandwidthEnabled": "Bant Genişliği Sınırlaması", + "bandwidthLimit": "Bant Genişliği Limiti" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d47c645b..e1251152 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -152,7 +152,15 @@ "json": "JSON", "jsonDesc": "Маршрут для метрик у форматі JSON", "prometheus": "Prometheus", - "prometheusDesc": "Маршрут для метрики Prometheus" + "prometheusDesc": "Маршрут для метрики Prometheus", + "bandwidth": "Обмеження пропускної здатності", + "bandwidthEnabled": "Увімкнути обмеження пропускної здатності", + "bandwidthEnabledDesc": "Застосувати глобальні обмеження пропускної здатності до всього VPN-трафіку", + "downloadLimit": "Ліміт завантаження (Mbps)", + "downloadLimitDesc": "Максимальна швидкість завантаження для клієнтів (0 = без обмежень)", + "uploadLimit": "Ліміт вивантаження (Mbps)", + "uploadLimitDesc": "Максимальна швидкість вивантаження від клієнтів (0 = без обмежень)", + "ifbWarning": "Обмеження вивантаження потребує модуль ядра IFB, який недоступний на цьому хості" }, "config": { "connection": "З'єднання", @@ -215,7 +223,9 @@ "general": { "sessionTimeout": "Час очікування сеансу", "metricsEnabled": "Метрики", - "metricsPassword": "Пароль метрик" + "metricsPassword": "Пароль метрик", + "bandwidthEnabled": "Обмеження пропускної здатності", + "bandwidthLimit": "Ліміт пропускної здатності" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b602284f..f7000789 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -152,7 +152,15 @@ "json": "JSON格式", "jsonDesc": "获取JSON格式的监控数据路由", "prometheus": "Prometheus格式", - "prometheusDesc": "获取Prometheus格式监控数据的路由" + "prometheusDesc": "获取Prometheus格式监控数据的路由", + "bandwidth": "带宽限制", + "bandwidthEnabled": "启用带宽限制", + "bandwidthEnabledDesc": "对所有VPN流量应用全局带宽限制", + "downloadLimit": "下载限制 (Mbps)", + "downloadLimitDesc": "客户端最大下载速度(0 = 不限制)", + "uploadLimit": "上传限制 (Mbps)", + "uploadLimitDesc": "客户端最大上传速度(0 = 不限制)", + "ifbWarning": "上传限制需要IFB内核模块,但该模块在此主机上不可用" }, "config": { "connection": "连接设置", @@ -215,7 +223,9 @@ "general": { "sessionTimeout": "会话超时", "metricsEnabled": "启用监控", - "metricsPassword": "监控密码" + "metricsPassword": "监控密码", + "bandwidthEnabled": "启用带宽限制", + "bandwidthLimit": "带宽限制值" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/zh-HK.json b/src/i18n/locales/zh-HK.json index 41e7001b..7c6a8586 100644 --- a/src/i18n/locales/zh-HK.json +++ b/src/i18n/locales/zh-HK.json @@ -146,7 +146,15 @@ "json": "JSON", "jsonDesc": "JSON格式指標的路由", "prometheus": "Prometheus", - "prometheusDesc": "Prometheus指標的路由" + "prometheusDesc": "Prometheus指標的路由", + "bandwidth": "頻寬限制", + "bandwidthEnabled": "啟用頻寬限制", + "bandwidthEnabledDesc": "對所有VPN流量應用全局頻寬限制", + "downloadLimit": "下載限制 (Mbps)", + "downloadLimitDesc": "客戶端最大下載速度(0 = 不限制)", + "uploadLimit": "上傳限制 (Mbps)", + "uploadLimitDesc": "客戶端最大上傳速度(0 = 不限制)", + "ifbWarning": "上傳限制需要IFB核心模組,但此主機上未能使用" }, "config": { "connection": "連線", @@ -209,7 +217,9 @@ "general": { "sessionTimeout": "工作階段逾時", "metricsEnabled": "指標", - "metricsPassword": "指標密碼" + "metricsPassword": "指標密碼", + "bandwidthEnabled": "啟用頻寬限制", + "bandwidthLimit": "頻寬限制值" }, "interface": { "cidr": "CIDR", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 6253473c..58c5a6fb 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -152,7 +152,15 @@ "json": "JSON", "jsonDesc": "提供 JSON 格式計量的路由", "prometheus": "Prometheus", - "prometheusDesc": "提供 Prometheus 計量的路由" + "prometheusDesc": "提供 Prometheus 計量的路由", + "bandwidth": "頻寬限制", + "bandwidthEnabled": "啟用頻寬限制", + "bandwidthEnabledDesc": "對所有 VPN 流量套用全域頻寬限制", + "downloadLimit": "下載限制 (Mbps)", + "downloadLimitDesc": "用戶端最大下載速度(0 = 不限制)", + "uploadLimit": "上傳限制 (Mbps)", + "uploadLimitDesc": "用戶端最大上傳速度(0 = 不限制)", + "ifbWarning": "上傳限制需要 IFB 核心模組,但此主機上無法使用" }, "config": { "connection": "連線", @@ -215,7 +223,9 @@ "general": { "sessionTimeout": "工作階段逾時", "metricsEnabled": "計量", - "metricsPassword": "計量密碼" + "metricsPassword": "計量密碼", + "bandwidthEnabled": "啟用頻寬限制", + "bandwidthLimit": "頻寬限制值" }, "interface": { "cidr": "CIDR", diff --git a/src/server/api/admin/bandwidth-status.get.ts b/src/server/api/admin/bandwidth-status.get.ts new file mode 100644 index 00000000..43ea9dd0 --- /dev/null +++ b/src/server/api/admin/bandwidth-status.get.ts @@ -0,0 +1,15 @@ +export default definePermissionEventHandler('admin', 'any', async () => { + const config = await Database.general.getBandwidthConfig(); + const ifbAvailable = WireGuard.getIfbAvailable(); + + return { + enabled: config.bandwidthEnabled, + downloadLimitMbps: config.downloadLimitMbps, + uploadLimitMbps: config.uploadLimitMbps, + ifbAvailable, + uploadLimitActive: + config.bandwidthEnabled && + config.uploadLimitMbps > 0 && + ifbAvailable, + }; +}); diff --git a/src/server/api/admin/general.post.ts b/src/server/api/admin/general.post.ts index 414af6d7..b6bb833c 100644 --- a/src/server/api/admin/general.post.ts +++ b/src/server/api/admin/general.post.ts @@ -8,7 +8,22 @@ export default definePermissionEventHandler( event, validateZod(GeneralUpdateSchema, event) ); + + // Get current settings to check if bandwidth changed + const current = await Database.general.getConfig(); + const bandwidthChanged = + current.bandwidthEnabled !== data.bandwidthEnabled || + current.downloadLimitMbps !== data.downloadLimitMbps || + current.uploadLimitMbps !== data.uploadLimitMbps; + await Database.general.update(data); + await WireGuard.saveConfig(); + + // Auto-restart interface if bandwidth settings changed + if (bandwidthChanged) { + await WireGuard.Restart(); + } + return { success: true }; } ); diff --git a/src/server/database/migrations/0003_bandwidth_limit.sql b/src/server/database/migrations/0003_bandwidth_limit.sql new file mode 100644 index 00000000..1451d74c --- /dev/null +++ b/src/server/database/migrations/0003_bandwidth_limit.sql @@ -0,0 +1,3 @@ +ALTER TABLE `general_table` ADD `bandwidth_enabled` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `general_table` ADD `download_limit_mbps` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `general_table` ADD `upload_limit_mbps` integer DEFAULT 0 NOT NULL; diff --git a/src/server/database/migrations/meta/0003_snapshot.json b/src/server/database/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..d9b0a01c --- /dev/null +++ b/src/server/database/migrations/meta/0003_snapshot.json @@ -0,0 +1,1000 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890", + "prevId": "e09bc17a-dab6-45a3-a09c-57af222b08fb", + "tables": { + "clients_table": { + "name": "clients_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interface_id": { + "name": "interface_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_address": { + "name": "ipv4_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_address": { + "name": "ipv6_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_shared_key": { + "name": "pre_shared_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_allowed_ips": { + "name": "server_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "persistent_keepalive": { + "name": "persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dns": { + "name": "dns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_endpoint": { + "name": "server_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "clients_table_ipv4_address_unique": { + "name": "clients_table_ipv4_address_unique", + "columns": [ + "ipv4_address" + ], + "isUnique": true + }, + "clients_table_ipv6_address_unique": { + "name": "clients_table_ipv6_address_unique", + "columns": [ + "ipv6_address" + ], + "isUnique": true + } + }, + "foreignKeys": { + "clients_table_user_id_users_table_id_fk": { + "name": "clients_table_user_id_users_table_id_fk", + "tableFrom": "clients_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "clients_table_interface_id_interfaces_table_name_fk": { + "name": "clients_table_interface_id_interfaces_table_name_fk", + "tableFrom": "clients_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "interface_id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "general_table": { + "name": "general_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "setup_step": { + "name": "setup_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_password": { + "name": "session_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_prometheus": { + "name": "metrics_prometheus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_json": { + "name": "metrics_json", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_password": { + "name": "metrics_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "bandwidth_enabled": { + "name": "bandwidth_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_limit_mbps": { + "name": "download_limit_mbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_limit_mbps": { + "name": "upload_limit_mbps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hooks_table": { + "name": "hooks_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "hooks_table_id_interfaces_table_name_fk": { + "name": "hooks_table_id_interfaces_table_name_fk", + "tableFrom": "hooks_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "interfaces_table": { + "name": "interfaces_table", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_cidr": { + "name": "ipv4_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_cidr": { + "name": "ipv6_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "s1": { + "name": "s1", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 128 + }, + "s2": { + "name": "s2", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 56 + }, + "s3": { + "name": "s3", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s4": { + "name": "s4", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1": { + "name": "h1", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "h2": { + "name": "h2", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "h3": { + "name": "h3", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "h4": { + "name": "h4", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "interfaces_table_port_unique": { + "name": "interfaces_table_port_unique", + "columns": [ + "port" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "one_time_links_table": { + "name": "one_time_links_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "one_time_link": { + "name": "one_time_link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "one_time_links_table_one_time_link_unique": { + "name": "one_time_links_table_one_time_link_unique", + "columns": [ + "one_time_link" + ], + "isUnique": true + } + }, + "foreignKeys": { + "one_time_links_table_id_clients_table_id_fk": { + "name": "one_time_links_table_id_clients_table_id_fk", + "tableFrom": "one_time_links_table", + "tableTo": "clients_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_key": { + "name": "totp_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified": { + "name": "totp_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_configs_table": { + "name": "user_configs_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_mtu": { + "name": "default_mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_persistent_keepalive": { + "name": "default_persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_dns": { + "name": "default_dns", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_allowed_ips": { + "name": "default_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_j_c": { + "name": "default_j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "default_j_min": { + "name": "default_j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "default_j_max": { + "name": "default_j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "default_i1": { + "name": "default_i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i2": { + "name": "default_i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i3": { + "name": "default_i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i4": { + "name": "default_i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i5": { + "name": "default_i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_configs_table_id_interfaces_table_name_fk": { + "name": "user_configs_table_id_interfaces_table_name_fk", + "tableFrom": "user_configs_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index 9e4a5f4c..8a2d8f9e 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/src/server/database/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1761298328460, "tag": "0002_keen_sleepwalker", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1735460000000, + "tag": "0003_bandwidth_limit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/database/repositories/general/schema.ts b/src/server/database/repositories/general/schema.ts index e1f4932e..bfc2cd4b 100644 --- a/src/server/database/repositories/general/schema.ts +++ b/src/server/database/repositories/general/schema.ts @@ -13,6 +13,12 @@ export const general = sqliteTable('general_table', { metricsJson: int('metrics_json', { mode: 'boolean' }).notNull(), metricsPassword: text('metrics_password'), + bandwidthEnabled: int('bandwidth_enabled', { mode: 'boolean' }) + .notNull() + .default(false), + downloadLimitMbps: int('download_limit_mbps').notNull().default(0), + uploadLimitMbps: int('upload_limit_mbps').notNull().default(0), + createdAt: text('created_at') .notNull() .default(sql`(CURRENT_TIMESTAMP)`), diff --git a/src/server/database/repositories/general/service.ts b/src/server/database/repositories/general/service.ts index ef629fd8..0b17b220 100644 --- a/src/server/database/repositories/general/service.ts +++ b/src/server/database/repositories/general/service.ts @@ -36,6 +36,18 @@ function createPreparedStatement(db: DBType) { metricsPrometheus: true, metricsJson: true, metricsPassword: true, + bandwidthEnabled: true, + downloadLimitMbps: true, + uploadLimitMbps: true, + }, + }) + .prepare(), + getBandwidthConfig: db.query.general + .findFirst({ + columns: { + bandwidthEnabled: true, + downloadLimitMbps: true, + uploadLimitMbps: true, }, }) .prepare(), @@ -128,4 +140,14 @@ export class GeneralService { return result; } + + async getBandwidthConfig() { + const result = await this.#statements.getBandwidthConfig.execute(); + + if (!result) { + throw new Error('General Config not found'); + } + + return result; + } } diff --git a/src/server/database/repositories/general/types.ts b/src/server/database/repositories/general/types.ts index e16bc4c6..6a728e0a 100644 --- a/src/server/database/repositories/general/types.ts +++ b/src/server/database/repositories/general/types.ts @@ -13,11 +13,21 @@ const metricsPassword = z .min(1, { message: t('zod.general.metricsPassword') }) .nullable(); +const bandwidthEnabled = z.boolean({ + message: t('zod.general.bandwidthEnabled'), +}); +const bandwidthLimit = z + .number({ message: t('zod.general.bandwidthLimit') }) + .min(0); + export const GeneralUpdateSchema = z.object({ sessionTimeout: sessionTimeout, metricsPrometheus: metricsEnabled, metricsJson: metricsEnabled, metricsPassword: metricsPassword, + bandwidthEnabled: bandwidthEnabled, + downloadLimitMbps: bandwidthLimit, + uploadLimitMbps: bandwidthLimit, }); export type GeneralUpdateType = z.infer; diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts index 048d866d..1fe6693a 100644 --- a/src/server/utils/WireGuard.ts +++ b/src/server/utils/WireGuard.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import debug from 'debug'; import { encodeQR } from 'qr'; import type { InterfaceType } from '#db/repositories/interface/types'; +import { checkIfbAvailable } from './bandwidth'; const WG_DEBUG = debug('WireGuard'); @@ -9,6 +10,15 @@ const generateRandomHeaderValue = () => Math.floor(Math.random() * 2147483642) + 5; class WireGuard { + #ifbAvailable: boolean = true; + + /** + * Get IFB availability status for upload limiting + */ + getIfbAvailable(): boolean { + return this.#ifbAvailable; + } + /** * Save and sync config */ @@ -26,11 +36,18 @@ class WireGuard { async #saveWireguardConfig(wgInterface: InterfaceType) { const clients = await Database.clients.getAll(); const hooks = await Database.hooks.get(); + const bandwidthConfig = await Database.general.getBandwidthConfig(); const result = []; result.push( wg.generateServerInterface(wgInterface, hooks, { enableIpv6: !WG_ENV.DISABLE_IPV6, + bandwidth: { + enabled: bandwidthConfig.bandwidthEnabled, + downloadMbps: bandwidthConfig.downloadLimitMbps, + uploadMbps: bandwidthConfig.uploadLimitMbps, + }, + ifbAvailable: this.#ifbAvailable, }) ); @@ -199,6 +216,12 @@ class WireGuard { // let as it has to refetch if keys change let wgInterface = await Database.interfaces.get(); + // Check IFB availability for upload limiting + this.#ifbAvailable = await checkIfbAvailable(); + if (!this.#ifbAvailable) { + WG_DEBUG('IFB module not available - upload limiting disabled'); + } + // default interface has no keys if ( wgInterface.privateKey === '---default---' && diff --git a/src/server/utils/bandwidth.ts b/src/server/utils/bandwidth.ts new file mode 100644 index 00000000..3610cba9 --- /dev/null +++ b/src/server/utils/bandwidth.ts @@ -0,0 +1,116 @@ +import debug from 'debug'; +import { exec } from './cmd'; + +const BW_DEBUG = debug('Bandwidth'); + +export interface BandwidthConfig { + enabled: boolean; + downloadMbps: number; + uploadMbps: number; +} + +export interface TcCommands { + postUp: string; + postDown: string; +} + +// Valid interface name pattern (e.g., wg0, eth0, enp0s3) +const VALID_INTERFACE_PATTERN = /^[a-zA-Z0-9_-]{1,15}$/; + +// Bandwidth limits (1 Mbps to 10 Gbps) +const MIN_BANDWIDTH_MBPS = 1; +const MAX_BANDWIDTH_MBPS = 10000; + +/** + * Validate interface name to prevent command injection + */ +function isValidInterfaceName(name: string): boolean { + return VALID_INTERFACE_PATTERN.test(name); +} + +/** + * Clamp bandwidth value to safe range + */ +function clampBandwidth(value: number): number { + if (value <= 0) return 0; + return Math.min(Math.max(value, MIN_BANDWIDTH_MBPS), MAX_BANDWIDTH_MBPS); +} + +/** + * Check if IFB kernel module is available for upload limiting + */ +export async function checkIfbAvailable(): Promise { + try { + await exec('modprobe ifb 2>/dev/null || lsmod | grep -q ifb', { + log: false, + }); + return true; + } catch { + BW_DEBUG('IFB module not available'); + return false; + } +} + +/** + * Generate tc commands for bandwidth limiting + * @param interfaceName - WireGuard interface name (e.g., wg0) + * @param config - Bandwidth configuration + * @param ifbAvailable - Whether IFB module is available for upload limiting + */ +export function generateTcCommands( + interfaceName: string, + config: BandwidthConfig, + ifbAvailable: boolean = true +): TcCommands { + // Validate interface name to prevent command injection + if (!isValidInterfaceName(interfaceName)) { + BW_DEBUG(`Invalid interface name: ${interfaceName}`); + return { postUp: '', postDown: '' }; + } + + // Clamp bandwidth values to safe range + const downloadMbps = clampBandwidth(config.downloadMbps); + const uploadMbps = clampBandwidth(config.uploadMbps); + + if (!config.enabled || (downloadMbps === 0 && uploadMbps === 0)) { + return { postUp: '', postDown: '' }; + } + + const postUpParts: string[] = []; + const postDownParts: string[] = []; + + // Download limit (egress on wg interface) + if (downloadMbps > 0) { + postUpParts.push( + `tc qdisc add dev ${interfaceName} root handle 1: htb default 10`, + `tc class add dev ${interfaceName} parent 1: classid 1:1 htb rate ${downloadMbps}mbit ceil ${downloadMbps}mbit`, + `tc class add dev ${interfaceName} parent 1:1 classid 1:10 htb rate ${downloadMbps}mbit ceil ${downloadMbps}mbit` + ); + postDownParts.push( + `tc qdisc del dev ${interfaceName} root 2>/dev/null || true` + ); + } + + // Upload limit (ingress via IFB mirror) + if (uploadMbps > 0 && ifbAvailable) { + const ifbDev = 'ifb0'; + postUpParts.push( + `ip link add ${ifbDev} type ifb 2>/dev/null || true`, + `ip link set ${ifbDev} up`, + `tc qdisc add dev ${interfaceName} handle ffff: ingress`, + `tc filter add dev ${interfaceName} parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev ${ifbDev}`, + `tc qdisc add dev ${ifbDev} root handle 1: htb default 10`, + `tc class add dev ${ifbDev} parent 1: classid 1:1 htb rate ${uploadMbps}mbit ceil ${uploadMbps}mbit`, + `tc class add dev ${ifbDev} parent 1:1 classid 1:10 htb rate ${uploadMbps}mbit ceil ${uploadMbps}mbit` + ); + postDownParts.push( + `tc qdisc del dev ${interfaceName} handle ffff: ingress 2>/dev/null || true`, + `ip link del ${ifbDev} 2>/dev/null || true` + ); + } + + return { + postUp: postUpParts.join('; '), + postDown: postDownParts.join('; '), + }; +} diff --git a/src/server/utils/wgHelper.ts b/src/server/utils/wgHelper.ts index d97baf3d..9c109900 100644 --- a/src/server/utils/wgHelper.ts +++ b/src/server/utils/wgHelper.ts @@ -4,11 +4,20 @@ import type { ClientType } from '#db/repositories/client/types'; import type { InterfaceType } from '#db/repositories/interface/types'; import type { UserConfigType } from '#db/repositories/userConfig/types'; import type { HooksType } from '#db/repositories/hooks/types'; +import { + generateTcCommands, + type BandwidthConfig, +} from './bandwidth'; type Options = { enableIpv6?: boolean; }; +type ServerInterfaceOptions = Options & { + bandwidth?: BandwidthConfig; + ifbAvailable?: boolean; +}; + const wgExecutable = WG_ENV.WG_EXECUTABLE; export const wg = { @@ -39,9 +48,9 @@ AllowedIPs = ${allowedIps.join(', ')}${extraLines.length ? `\n${extraLines.join( generateServerInterface: ( wgInterface: InterfaceType, hooks: HooksType, - options: Options = {} + options: ServerInterfaceOptions = {} ) => { - const { enableIpv6 = true } = options; + const { enableIpv6 = true, bandwidth, ifbAvailable = true } = options; const cidr4 = parseCidr(wgInterface.ipv4Cidr); const cidr6 = parseCidr(wgInterface.ipv6Cidr); @@ -81,6 +90,36 @@ AllowedIPs = ${allowedIps.join(', ')}${extraLines.length ? `\n${extraLines.join( const extraLines = [...awgLines].filter((v) => v !== null); + // Generate tc commands for bandwidth limiting + let tcPostUp = ''; + let tcPostDown = ''; + if (bandwidth) { + const tcCommands = generateTcCommands( + wgInterface.name, + bandwidth, + ifbAvailable + ); + tcPostUp = tcCommands.postUp; + tcPostDown = tcCommands.postDown; + } + + // Combine hooks with tc commands (strip trailing semicolons to avoid ;;) + const stripTrailingSemicolon = (s: string) => s.replace(/;+\s*$/, ''); + const finalPostUp = [ + iptablesTemplate(hooks.postUp, wgInterface), + tcPostUp, + ] + .map((s) => stripTrailingSemicolon(s)) + .filter(Boolean) + .join('; '); + const finalPostDown = [ + tcPostDown, + iptablesTemplate(hooks.postDown, wgInterface), + ] + .map((s) => stripTrailingSemicolon(s)) + .filter(Boolean) + .join('; '); + return `# Note: Do not edit this file directly. # Your changes will be overwritten! @@ -92,9 +131,9 @@ ListenPort = ${wgInterface.port} MTU = ${wgInterface.mtu} ${extraLines.length ? `${extraLines.join('\n')}\n` : ''} PreUp = ${iptablesTemplate(hooks.preUp, wgInterface)} -PostUp = ${iptablesTemplate(hooks.postUp, wgInterface)} +PostUp = ${finalPostUp} PreDown = ${iptablesTemplate(hooks.preDown, wgInterface)} -PostDown = ${iptablesTemplate(hooks.postDown, wgInterface)}`; +PostDown = ${finalPostDown}`; }, generateClientConfig: (