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
This commit is contained in:
infosave2007
2025-11-08 09:56:25 +03:00
parent 5510e0b7b1
commit b6cf9cbab7
13 changed files with 378 additions and 16 deletions
+2 -1
View File
@@ -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
+46 -3
View File
@@ -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 <token>"
```
### 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 <token>" \
-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 <token>" \
-d '{"limit_bytes": null}'
# Check traffic limit status
curl http://localhost:8082/api/clients/123/traffic-limit-status \
-H "Authorization: Bearer <token>"
# Get clients over traffic limit
curl http://localhost:8082/api/clients/overlimit \
-H "Authorization: Bearer <token>"
```
### 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
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env php
<?php
/**
* Check and disable clients that exceeded their traffic limit
* Run this script via cron every hour
*/
require_once __DIR__ . '/../inc/Config.php';
require_once __DIR__ . '/../inc/DB.php';
require_once __DIR__ . '/../inc/VpnClient.php';
require_once __DIR__ . '/../inc/VpnServer.php';
// Load config
Config::load(__DIR__ . '/../.env');
echo '[' . date('Y-m-d H:i:s') . '] Checking for clients over traffic limit...' . PHP_EOL;
try {
$disabled = VpnClient::disableClientsOverLimit();
if ($disabled > 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);
+112
View File
@@ -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;
}
}
+3
View File
@@ -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'),
+5 -2
View File
@@ -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', 'Добавить первый сервер'),
+5 -2
View File
@@ -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'),
+5 -2
View File
@@ -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'),
+5 -2
View File
@@ -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'),
+5 -2
View File
@@ -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', '添加第一个服务器'),
+6
View File
@@ -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);
+113
View File
@@ -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
*/
+40 -2
View File
@@ -28,6 +28,21 @@
<option value="365">365 {{ t('common.days') }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">{{ t('clients.traffic_limit') }}</label>
<select name="traffic_limit_gb" class="w-full px-3 py-2 border rounded">
<option value="" selected>{{ t('clients.unlimited') }}</option>
<option value="1">1 GB</option>
<option value="5">5 GB</option>
<option value="10">10 GB</option>
<option value="25">25 GB</option>
<option value="50">50 GB</option>
<option value="100">100 GB</option>
<option value="250">250 GB</option>
<option value="500">500 GB</option>
<option value="1000">1000 GB (1 TB)</option>
</select>
</div>
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded w-full" id="createClientBtn">
<span id="createClientText">{{ t('form.create') }}</span>
<i class="fas fa-spinner fa-spin" id="createClientSpinner" style="display:none;"></i>
@@ -66,6 +81,7 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.status') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.expiration') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic_limit') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.last_handshake') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th>
</tr>
@@ -105,12 +121,34 @@
</td>
<td class="px-6 py-4 text-sm">
<div class="text-gray-600">
{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
{{ (client.traffic_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
<div class="text-gray-600">
{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
{{ (client.traffic_received|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
</td>
<td class="px-6 py-4 text-sm">
{% if client.traffic_limit %}
{% set total_traffic = (client.traffic_sent|default(0) + client.traffic_received|default(0)) %}
{% set limit_gb = (client.traffic_limit / 1073741824)|number_format(2) %}
{% set used_gb = (total_traffic / 1073741824)|number_format(2) %}
{% set percentage = ((total_traffic / client.traffic_limit) * 100)|round %}
{% if percentage >= 100 %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
<i class="fas fa-exclamation-circle"></i> {{ t('clients.overlimit') }}
</span>
{% elseif percentage >= 80 %}
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
</span>
{% else %}
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{% if client.last_handshake %}
<span class="text-gray-600">{{ client.last_handshake }}</span>