From b6cf9cbab7a89b980515d6691f1784c9dbd13c3d Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 8 Nov 2025 09:56:25 +0300 Subject: [PATCH] Add traffic limit functionality - Add traffic_limit field to vpn_clients table (migration 007) - Add traffic limit management methods in VpnClient class - Add API endpoints: set-traffic-limit, traffic-limit-status, overlimit - Add UI for setting limits when creating clients - Add traffic limit column in clients table with visual indicators - Add automatic traffic limit check via cron (bin/check_traffic_limits.php) - Add translations for traffic limits (6 languages) - Update README with traffic limit documentation --- Dockerfile | 3 +- README.md | 49 +++++++++++- bin/check_traffic_limits.php | 31 ++++++++ inc/VpnClient.php | 112 ++++++++++++++++++++++++++ migrations/001_init.sql | 3 + migrations/002_translations_ru.sql | 7 +- migrations/003_translations_es.sql | 7 +- migrations/004_translations_de.sql | 7 +- migrations/005_translations_fr.sql | 7 +- migrations/006_translations_zh.sql | 7 +- migrations/007_add_traffic_limit.sql | 6 ++ public/index.php | 113 +++++++++++++++++++++++++++ templates/servers/view.twig | 42 +++++++++- 13 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 bin/check_traffic_limits.php create mode 100644 migrations/007_add_traffic_limit.sql diff --git a/Dockerfile b/Dockerfile index 859ee76..640bc57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,8 +36,9 @@ COPY apache.conf /etc/apache2/sites-available/000-default.conf RUN chown -R www-data:www-data /var/www/html \ && chmod -R 755 /var/www/html/public -# Setup cron for client expiration check (runs every hour) +# Setup cron for client expiration and traffic limit checks (runs every hour) RUN echo "0 * * * * www-data cd /var/www/html && /usr/local/bin/php bin/check_expired_clients.php >> /var/log/cron.log 2>&1" > /etc/cron.d/amnezia-cron \ + && echo "0 * * * * www-data cd /var/www/html && /usr/local/bin/php bin/check_traffic_limits.php >> /var/log/cron.log 2>&1" >> /etc/cron.d/amnezia-cron \ && chmod 0644 /etc/cron.d/amnezia-cron \ && crontab /etc/cron.d/amnezia-cron \ && touch /var/log/cron.log diff --git a/README.md b/README.md index 650d9f8..8e8ea99 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers. - VPN server deployment via SSH - Client configuration management with **expiration dates** +- **Traffic limits** for clients with automatic enforcement - **Server backup and restore** functionality - Traffic statistics monitoring - QR code generation for mobile apps - Multi-language interface (English, Russian, Spanish, German, French, Chinese) - REST API with JWT authentication - User authentication and access control -- **Automatic client expiration check** via cron +- **Automatic client expiration and traffic limit checks** via cron ## Requirements @@ -64,8 +65,9 @@ JWT_SECRET=your-secret-key-change-this 1. Open server details 2. Enter client name 3. **Select expiration period** (optional, default: never expires) -4. Click Create Client -5. Download config or scan QR code +4. **Select traffic limit** (optional, default: unlimited) +5. Click Create Client +6. Download config or scan QR code ### Manage Client Expiration @@ -86,6 +88,29 @@ curl http://localhost:8082/api/clients/expiring?days=7 \ -H "Authorization: Bearer " ``` +### Manage Traffic Limits + +Set and monitor traffic limits via UI or API: +```bash +# Set traffic limit (10 GB = 10737418240 bytes) +curl -X POST http://localhost:8082/api/clients/123/set-traffic-limit \ + -H "Authorization: Bearer " \ + -d '{"limit_bytes": 10737418240}' + +# Remove traffic limit (set to unlimited) +curl -X POST http://localhost:8082/api/clients/123/set-traffic-limit \ + -H "Authorization: Bearer " \ + -d '{"limit_bytes": null}' + +# Check traffic limit status +curl http://localhost:8082/api/clients/123/traffic-limit-status \ + -H "Authorization: Bearer " + +# Get clients over traffic limit +curl http://localhost:8082/api/clients/overlimit \ + -H "Authorization: Bearer " +``` + ### Server Backups Create and restore backups via UI or API: @@ -118,6 +143,20 @@ Run manually: docker compose exec web php /var/www/html/bin/check_expired_clients.php ``` +### Automatic Traffic Limit Check + +**Runs automatically in Docker container** every hour to disable clients that exceeded their traffic limit. + +Check cron logs: +```bash +docker compose exec web tail -f /var/log/cron.log +``` + +Run manually: +```bash +docker compose exec web php /var/www/html/bin/check_traffic_limits.php +``` + ### API Authentication Get JWT token: @@ -167,6 +206,10 @@ POST /api/clients/{id}/extend - Extend client expiration Parameters: days (int) GET /api/clients/expiring - Get clients expiring soon Parameters: days (default: 7) +POST /api/clients/{id}/set-traffic-limit - Set client traffic limit + Parameters: limit_bytes (int or null for unlimited) +GET /api/clients/{id}/traffic-limit-status - Get traffic limit status +GET /api/clients/overlimit - Get clients over traffic limit ``` ### Backups diff --git a/bin/check_traffic_limits.php b/bin/check_traffic_limits.php new file mode 100644 index 0000000..7475211 --- /dev/null +++ b/bin/check_traffic_limits.php @@ -0,0 +1,31 @@ +#!/usr/bin/env php + 0) { + echo '[' . date('Y-m-d H:i:s') . '] Disabled ' . $disabled . ' client(s) that exceeded traffic limit' . PHP_EOL; + } else { + echo '[' . date('Y-m-d H:i:s') . '] No clients over traffic limit' . PHP_EOL; + } +} catch (Exception $e) { + echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $e->getMessage() . PHP_EOL; + exit(1); +} + +exit(0); diff --git a/inc/VpnClient.php b/inc/VpnClient.php index cea7c35..f2d5c61 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -825,5 +825,117 @@ class VpnClient { $diff = strtotime($this->data['expires_at']) - time(); return (int)floor($diff / 86400); } + + /** + * Set traffic limit for client + * + * @param int|null $limitBytes Traffic limit in bytes (NULL = unlimited) + * @return bool Success + */ + public function setTrafficLimit(?int $limitBytes): bool { + if (!$this->data) { + throw new Exception('Client not loaded'); + } + + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_clients SET traffic_limit = ? WHERE id = ?'); + $result = $stmt->execute([$limitBytes, $this->clientId]); + + if ($result) { + $this->data['traffic_limit'] = $limitBytes; + } + + return $result; + } + + /** + * Get total traffic used (sent + received) + * + * @return int Total traffic in bytes + */ + public function getTotalTraffic(): int { + if (!$this->data) { + return 0; + } + + return (int)($this->data['traffic_sent'] ?? 0) + (int)($this->data['traffic_received'] ?? 0); + } + + /** + * Check if client has exceeded traffic limit + * + * @return bool True if over limit + */ + public function isOverLimit(): bool { + if (!$this->data || $this->data['traffic_limit'] === null) { + return false; // No limit set + } + + $totalTraffic = $this->getTotalTraffic(); + return $totalTraffic >= (int)$this->data['traffic_limit']; + } + + /** + * Get traffic limit status + * + * @return array Status info + */ + public function getTrafficLimitStatus(): array { + $totalTraffic = $this->getTotalTraffic(); + $limit = $this->data['traffic_limit'] ?? null; + + return [ + 'total_traffic' => $totalTraffic, + 'traffic_limit' => $limit, + 'is_unlimited' => $limit === null, + 'is_over_limit' => $this->isOverLimit(), + 'percentage_used' => $limit ? min(100, round(($totalTraffic / $limit) * 100, 2)) : 0, + 'remaining' => $limit ? max(0, $limit - $totalTraffic) : null + ]; + } + + /** + * Get all clients that exceeded their traffic limit + * + * @return array List of client IDs over limit + */ + public static function getClientsOverLimit(): array { + $pdo = DB::conn(); + $stmt = $pdo->query(' + SELECT id, name, traffic_sent, traffic_received, traffic_limit + FROM vpn_clients + WHERE traffic_limit IS NOT NULL + AND (traffic_sent + traffic_received) >= traffic_limit + AND status = "active" + ORDER BY id + '); + + return $stmt->fetchAll(); + } + + /** + * Disable all clients that exceeded their traffic limit + * + * @return int Number of clients disabled + */ + public static function disableClientsOverLimit(): int { + $clients = self::getClientsOverLimit(); + $disabled = 0; + + foreach ($clients as $clientData) { + try { + $client = new VpnClient($clientData['id']); + if ($client->revoke()) { + $disabled++; + error_log("Client {$clientData['name']} (ID: {$clientData['id']}) disabled: traffic limit exceeded"); + } + } catch (Exception $e) { + error_log("Failed to disable client {$clientData['id']}: " . $e->getMessage()); + } + } + + return $disabled; + } } + diff --git a/migrations/001_init.sql b/migrations/001_init.sql index d5cb523..739a1de 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -205,6 +205,9 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('en', 'clients.sync_stats', 'Sync Stats'), ('en', 'clients.title', 'Clients'), ('en', 'clients.traffic', 'Traffic'), +('en', 'clients.traffic_limit', 'Traffic Limit'), +('en', 'clients.unlimited', 'Unlimited'), +('en', 'clients.overlimit', 'Over Limit'), ('en', 'backups.title', 'Server Backups'), ('en', 'backups.create', 'Create Backup'), ('en', 'backups.restore', 'Restore'), diff --git a/migrations/002_translations_ru.sql b/migrations/002_translations_ru.sql index e101c54..2f10516 100644 --- a/migrations/002_translations_ru.sql +++ b/migrations/002_translations_ru.sql @@ -1,5 +1,5 @@ --- Russian translations --- This migration adds Russian language translations +-- RU translations +-- This migration adds RU language translations INSERT INTO translations (language_code, translation_key, translation_value) VALUES ('ru', 'auth.email', 'Email'), @@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('ru', 'clients.never', 'Никогда'), ('ru', 'clients.never_expires', 'Бессрочно'), ('ru', 'clients.no_clients', 'Пока нет клиентов'), +('ru', 'clients.overlimit', 'Превышен лимит'), ('ru', 'clients.qr_code', 'QR-код'), ('ru', 'clients.received', 'Получено'), ('ru', 'clients.restore', 'Восстановить'), @@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('ru', 'clients.sync_stats', 'Синхронизировать статистику'), ('ru', 'clients.title', 'Клиенты'), ('ru', 'clients.traffic', 'Трафик'), +('ru', 'clients.traffic_limit', 'Лимит трафика'), +('ru', 'clients.unlimited', 'Безлимитно'), ('ru', 'common.days', 'дней'), ('ru', 'dashboard.active_clients', 'Активные клиенты'), ('ru', 'dashboard.add_first_server', 'Добавить первый сервер'), diff --git a/migrations/003_translations_es.sql b/migrations/003_translations_es.sql index c491bf9..be98810 100644 --- a/migrations/003_translations_es.sql +++ b/migrations/003_translations_es.sql @@ -1,5 +1,5 @@ --- Spanish translations --- This migration adds Spanish language translations +-- ES translations +-- This migration adds ES language translations INSERT INTO translations (language_code, translation_key, translation_value) VALUES ('es', 'auth.email', 'Correo electrónico'), @@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('es', 'clients.never', 'Nunca'), ('es', 'clients.never_expires', 'Nunca vence'), ('es', 'clients.no_clients', 'Aún no hay clientes'), +('es', 'clients.overlimit', 'Límite excedido'), ('es', 'clients.qr_code', 'Código QR'), ('es', 'clients.received', 'Recibido'), ('es', 'clients.restore', 'Restaurar'), @@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('es', 'clients.sync_stats', 'Sincronizar estadísticas'), ('es', 'clients.title', 'Clientes'), ('es', 'clients.traffic', 'Tráfico'), +('es', 'clients.traffic_limit', 'Límite de tráfico'), +('es', 'clients.unlimited', 'Ilimitado'), ('es', 'common.days', 'días'), ('es', 'dashboard.active_clients', 'Clientes activos'), ('es', 'dashboard.add_first_server', 'Agregar primer servidor'), diff --git a/migrations/004_translations_de.sql b/migrations/004_translations_de.sql index 150df1c..1ebcfa7 100644 --- a/migrations/004_translations_de.sql +++ b/migrations/004_translations_de.sql @@ -1,5 +1,5 @@ --- German translations --- This migration adds German language translations +-- DE translations +-- This migration adds DE language translations INSERT INTO translations (language_code, translation_key, translation_value) VALUES ('de', 'auth.email', 'E-Mail'), @@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('de', 'clients.never', 'Niemals'), ('de', 'clients.never_expires', 'Läuft nie ab'), ('de', 'clients.no_clients', 'Noch keine Kunden'), +('de', 'clients.overlimit', 'Limit überschritten'), ('de', 'clients.qr_code', 'QR-Code'), ('de', 'clients.received', 'Empfangen'), ('de', 'clients.restore', 'Wiederherstellen'), @@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('de', 'clients.sync_stats', 'Statistiken synchronisieren'), ('de', 'clients.title', 'Clients'), ('de', 'clients.traffic', 'Datenverkehr'), +('de', 'clients.traffic_limit', 'Traffic-Limit'), +('de', 'clients.unlimited', 'Unbegrenzt'), ('de', 'common.days', 'Tage'), ('de', 'dashboard.active_clients', 'Aktive Clients'), ('de', 'dashboard.add_first_server', 'Ersten Server hinzufügen'), diff --git a/migrations/005_translations_fr.sql b/migrations/005_translations_fr.sql index 7edefb9..b416972 100644 --- a/migrations/005_translations_fr.sql +++ b/migrations/005_translations_fr.sql @@ -1,5 +1,5 @@ --- French translations --- This migration adds French language translations +-- FR translations +-- This migration adds FR language translations INSERT INTO translations (language_code, translation_key, translation_value) VALUES ('fr', 'auth.email', 'Email'), @@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('fr', 'clients.never', 'Jamais'), ('fr', 'clients.never_expires', 'N''expire jamais'), ('fr', 'clients.no_clients', 'Pas encore de clients'), +('fr', 'clients.overlimit', 'Limite dépassée'), ('fr', 'clients.qr_code', 'Code QR'), ('fr', 'clients.received', 'Reçu'), ('fr', 'clients.restore', 'Restaurer'), @@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('fr', 'clients.sync_stats', 'Synchroniser les statistiques'), ('fr', 'clients.title', 'Clients'), ('fr', 'clients.traffic', 'Trafic'), +('fr', 'clients.traffic_limit', 'Limite de trafic'), +('fr', 'clients.unlimited', 'Illimité'), ('fr', 'common.days', 'jours'), ('fr', 'dashboard.active_clients', 'Clients actifs'), ('fr', 'dashboard.add_first_server', 'Ajouter le premier serveur'), diff --git a/migrations/006_translations_zh.sql b/migrations/006_translations_zh.sql index 33343ae..68cfcac 100644 --- a/migrations/006_translations_zh.sql +++ b/migrations/006_translations_zh.sql @@ -1,5 +1,5 @@ --- Chinese translations --- This migration adds Chinese language translations +-- ZH translations +-- This migration adds ZH language translations INSERT INTO translations (language_code, translation_key, translation_value) VALUES ('zh', 'auth.email', '邮箱'), @@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('zh', 'clients.never', '从不'), ('zh', 'clients.never_expires', '永不过期'), ('zh', 'clients.no_clients', '还没有客户'), +('zh', 'clients.overlimit', '超出限制'), ('zh', 'clients.qr_code', '二维码'), ('zh', 'clients.received', '已接收'), ('zh', 'clients.restore', '恢复'), @@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL ('zh', 'clients.sync_stats', '同步统计'), ('zh', 'clients.title', '客户端'), ('zh', 'clients.traffic', '流量'), +('zh', 'clients.traffic_limit', '流量限制'), +('zh', 'clients.unlimited', '无限制'), ('zh', 'common.days', '天'), ('zh', 'dashboard.active_clients', '活跃客户端'), ('zh', 'dashboard.add_first_server', '添加第一个服务器'), diff --git a/migrations/007_add_traffic_limit.sql b/migrations/007_add_traffic_limit.sql new file mode 100644 index 0000000..bdbaf06 --- /dev/null +++ b/migrations/007_add_traffic_limit.sql @@ -0,0 +1,6 @@ +-- Add traffic limit field to vpn_clients table +-- This migration adds traffic limit functionality to clients + +ALTER TABLE vpn_clients +ADD COLUMN traffic_limit BIGINT UNSIGNED NULL COMMENT 'Traffic limit in bytes (NULL = unlimited)' AFTER traffic_received, +ADD INDEX idx_traffic_limit (traffic_limit); diff --git a/public/index.php b/public/index.php index 13b65ee..59e9d5c 100644 --- a/public/index.php +++ b/public/index.php @@ -396,6 +396,7 @@ Router::post('/servers/{id}/clients/create', function ($params) { $serverId = (int)$params['id']; $clientName = trim($_POST['name'] ?? ''); $expiresInDays = !empty($_POST['expires_in_days']) ? (int)$_POST['expires_in_days'] : null; + $trafficLimitGb = !empty($_POST['traffic_limit_gb']) ? (float)$_POST['traffic_limit_gb'] : null; if (empty($clientName)) { redirect('/servers/' . $serverId . '?error=Client+name+is+required'); @@ -415,6 +416,14 @@ Router::post('/servers/{id}/clients/create', function ($params) { } $clientId = VpnClient::create($serverId, $user['id'], $clientName, $expiresInDays); + + // Set traffic limit if specified + if ($trafficLimitGb !== null) { + $client = new VpnClient($clientId); + $limitBytes = (int)($trafficLimitGb * 1073741824); // Convert GB to bytes + $client->setTrafficLimit($limitBytes); + } + redirect('/clients/' . $clientId); } catch (Exception $e) { redirect('/servers/' . $serverId . '?error=' . urlencode($e->getMessage())); @@ -1322,6 +1331,110 @@ Router::get('/api/clients/expiring', function () { } }); +// Set client traffic limit +Router::post('/api/clients/{id}/set-traffic-limit', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $clientId = (int)$params['id']; + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + + // limit_bytes can be null (unlimited) or positive integer + $limitBytes = isset($data['limit_bytes']) ? (int)$data['limit_bytes'] : null; + + if ($limitBytes !== null && $limitBytes < 0) { + http_response_code(400); + echo json_encode(['error' => 'limit_bytes must be positive or null for unlimited']); + return; + } + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $client->setTrafficLimit($limitBytes); + + echo json_encode([ + 'success' => true, + 'limit_bytes' => $limitBytes, + 'limit_gb' => $limitBytes ? round($limitBytes / 1073741824, 2) : null + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// Check client traffic limit status +Router::get('/api/clients/{id}/traffic-limit-status', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $clientId = (int)$params['id']; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + if ($clientData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $status = $client->getTrafficLimitStatus(); + + echo json_encode([ + 'success' => true, + 'status' => $status + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// Get clients over traffic limit +Router::get('/api/clients/overlimit', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + try { + $clients = VpnClient::getClientsOverLimit(); + + // Filter by user if not admin + if ($user['role'] !== 'admin') { + $clients = array_filter($clients, function($c) use ($user) { + return $c['user_id'] == $user['id']; + }); + } + + echo json_encode([ + 'success' => true, + 'clients' => array_values($clients), + 'count' => count($clients) + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + /** * SETTINGS ROUTES */ diff --git a/templates/servers/view.twig b/templates/servers/view.twig index beb376c..b077f38 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -28,6 +28,21 @@ +
+ + +