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 \ RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/public && 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 \ 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 \ && chmod 0644 /etc/cron.d/amnezia-cron \
&& crontab /etc/cron.d/amnezia-cron \ && crontab /etc/cron.d/amnezia-cron \
&& touch /var/log/cron.log && 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 - VPN server deployment via SSH
- Client configuration management with **expiration dates** - Client configuration management with **expiration dates**
- **Traffic limits** for clients with automatic enforcement
- **Server backup and restore** functionality - **Server backup and restore** functionality
- Traffic statistics monitoring - Traffic statistics monitoring
- QR code generation for mobile apps - QR code generation for mobile apps
- Multi-language interface (English, Russian, Spanish, German, French, Chinese) - Multi-language interface (English, Russian, Spanish, German, French, Chinese)
- REST API with JWT authentication - REST API with JWT authentication
- User authentication and access control - User authentication and access control
- **Automatic client expiration check** via cron - **Automatic client expiration and traffic limit checks** via cron
## Requirements ## Requirements
@@ -64,8 +65,9 @@ JWT_SECRET=your-secret-key-change-this
1. Open server details 1. Open server details
2. Enter client name 2. Enter client name
3. **Select expiration period** (optional, default: never expires) 3. **Select expiration period** (optional, default: never expires)
4. Click Create Client 4. **Select traffic limit** (optional, default: unlimited)
5. Download config or scan QR code 5. Click Create Client
6. Download config or scan QR code
### Manage Client Expiration ### Manage Client Expiration
@@ -86,6 +88,29 @@ curl http://localhost:8082/api/clients/expiring?days=7 \
-H "Authorization: Bearer <token>" -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 ### Server Backups
Create and restore backups via UI or API: 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 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 ### API Authentication
Get JWT token: Get JWT token:
@@ -167,6 +206,10 @@ POST /api/clients/{id}/extend - Extend client expiration
Parameters: days (int) Parameters: days (int)
GET /api/clients/expiring - Get clients expiring soon GET /api/clients/expiring - Get clients expiring soon
Parameters: days (default: 7) 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 ### 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(); $diff = strtotime($this->data['expires_at']) - time();
return (int)floor($diff / 86400); 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.sync_stats', 'Sync Stats'),
('en', 'clients.title', 'Clients'), ('en', 'clients.title', 'Clients'),
('en', 'clients.traffic', 'Traffic'), ('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.title', 'Server Backups'),
('en', 'backups.create', 'Create Backup'), ('en', 'backups.create', 'Create Backup'),
('en', 'backups.restore', 'Restore'), ('en', 'backups.restore', 'Restore'),
+5 -2
View File
@@ -1,5 +1,5 @@
-- Russian translations -- RU translations
-- This migration adds Russian language translations -- This migration adds RU language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('ru', 'auth.email', 'Email'), ('ru', 'auth.email', 'Email'),
@@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('ru', 'clients.never', 'Никогда'), ('ru', 'clients.never', 'Никогда'),
('ru', 'clients.never_expires', 'Бессрочно'), ('ru', 'clients.never_expires', 'Бессрочно'),
('ru', 'clients.no_clients', 'Пока нет клиентов'), ('ru', 'clients.no_clients', 'Пока нет клиентов'),
('ru', 'clients.overlimit', 'Превышен лимит'),
('ru', 'clients.qr_code', 'QR-код'), ('ru', 'clients.qr_code', 'QR-код'),
('ru', 'clients.received', 'Получено'), ('ru', 'clients.received', 'Получено'),
('ru', 'clients.restore', 'Восстановить'), ('ru', 'clients.restore', 'Восстановить'),
@@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('ru', 'clients.sync_stats', 'Синхронизировать статистику'), ('ru', 'clients.sync_stats', 'Синхронизировать статистику'),
('ru', 'clients.title', 'Клиенты'), ('ru', 'clients.title', 'Клиенты'),
('ru', 'clients.traffic', 'Трафик'), ('ru', 'clients.traffic', 'Трафик'),
('ru', 'clients.traffic_limit', 'Лимит трафика'),
('ru', 'clients.unlimited', 'Безлимитно'),
('ru', 'common.days', 'дней'), ('ru', 'common.days', 'дней'),
('ru', 'dashboard.active_clients', 'Активные клиенты'), ('ru', 'dashboard.active_clients', 'Активные клиенты'),
('ru', 'dashboard.add_first_server', 'Добавить первый сервер'), ('ru', 'dashboard.add_first_server', 'Добавить первый сервер'),
+5 -2
View File
@@ -1,5 +1,5 @@
-- Spanish translations -- ES translations
-- This migration adds Spanish language translations -- This migration adds ES language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('es', 'auth.email', 'Correo electrónico'), ('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', 'Nunca'),
('es', 'clients.never_expires', 'Nunca vence'), ('es', 'clients.never_expires', 'Nunca vence'),
('es', 'clients.no_clients', 'Aún no hay clientes'), ('es', 'clients.no_clients', 'Aún no hay clientes'),
('es', 'clients.overlimit', 'Límite excedido'),
('es', 'clients.qr_code', 'Código QR'), ('es', 'clients.qr_code', 'Código QR'),
('es', 'clients.received', 'Recibido'), ('es', 'clients.received', 'Recibido'),
('es', 'clients.restore', 'Restaurar'), ('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.sync_stats', 'Sincronizar estadísticas'),
('es', 'clients.title', 'Clientes'), ('es', 'clients.title', 'Clientes'),
('es', 'clients.traffic', 'Tráfico'), ('es', 'clients.traffic', 'Tráfico'),
('es', 'clients.traffic_limit', 'Límite de tráfico'),
('es', 'clients.unlimited', 'Ilimitado'),
('es', 'common.days', 'días'), ('es', 'common.days', 'días'),
('es', 'dashboard.active_clients', 'Clientes activos'), ('es', 'dashboard.active_clients', 'Clientes activos'),
('es', 'dashboard.add_first_server', 'Agregar primer servidor'), ('es', 'dashboard.add_first_server', 'Agregar primer servidor'),
+5 -2
View File
@@ -1,5 +1,5 @@
-- German translations -- DE translations
-- This migration adds German language translations -- This migration adds DE language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('de', 'auth.email', 'E-Mail'), ('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', 'Niemals'),
('de', 'clients.never_expires', 'Läuft nie ab'), ('de', 'clients.never_expires', 'Läuft nie ab'),
('de', 'clients.no_clients', 'Noch keine Kunden'), ('de', 'clients.no_clients', 'Noch keine Kunden'),
('de', 'clients.overlimit', 'Limit überschritten'),
('de', 'clients.qr_code', 'QR-Code'), ('de', 'clients.qr_code', 'QR-Code'),
('de', 'clients.received', 'Empfangen'), ('de', 'clients.received', 'Empfangen'),
('de', 'clients.restore', 'Wiederherstellen'), ('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.sync_stats', 'Statistiken synchronisieren'),
('de', 'clients.title', 'Clients'), ('de', 'clients.title', 'Clients'),
('de', 'clients.traffic', 'Datenverkehr'), ('de', 'clients.traffic', 'Datenverkehr'),
('de', 'clients.traffic_limit', 'Traffic-Limit'),
('de', 'clients.unlimited', 'Unbegrenzt'),
('de', 'common.days', 'Tage'), ('de', 'common.days', 'Tage'),
('de', 'dashboard.active_clients', 'Aktive Clients'), ('de', 'dashboard.active_clients', 'Aktive Clients'),
('de', 'dashboard.add_first_server', 'Ersten Server hinzufügen'), ('de', 'dashboard.add_first_server', 'Ersten Server hinzufügen'),
+5 -2
View File
@@ -1,5 +1,5 @@
-- French translations -- FR translations
-- This migration adds French language translations -- This migration adds FR language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('fr', 'auth.email', 'Email'), ('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', 'Jamais'),
('fr', 'clients.never_expires', 'N''expire jamais'), ('fr', 'clients.never_expires', 'N''expire jamais'),
('fr', 'clients.no_clients', 'Pas encore de clients'), ('fr', 'clients.no_clients', 'Pas encore de clients'),
('fr', 'clients.overlimit', 'Limite dépassée'),
('fr', 'clients.qr_code', 'Code QR'), ('fr', 'clients.qr_code', 'Code QR'),
('fr', 'clients.received', 'Reçu'), ('fr', 'clients.received', 'Reçu'),
('fr', 'clients.restore', 'Restaurer'), ('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.sync_stats', 'Synchroniser les statistiques'),
('fr', 'clients.title', 'Clients'), ('fr', 'clients.title', 'Clients'),
('fr', 'clients.traffic', 'Trafic'), ('fr', 'clients.traffic', 'Trafic'),
('fr', 'clients.traffic_limit', 'Limite de trafic'),
('fr', 'clients.unlimited', 'Illimité'),
('fr', 'common.days', 'jours'), ('fr', 'common.days', 'jours'),
('fr', 'dashboard.active_clients', 'Clients actifs'), ('fr', 'dashboard.active_clients', 'Clients actifs'),
('fr', 'dashboard.add_first_server', 'Ajouter le premier serveur'), ('fr', 'dashboard.add_first_server', 'Ajouter le premier serveur'),
+5 -2
View File
@@ -1,5 +1,5 @@
-- Chinese translations -- ZH translations
-- This migration adds Chinese language translations -- This migration adds ZH language translations
INSERT INTO translations (language_code, translation_key, translation_value) VALUES INSERT INTO translations (language_code, translation_key, translation_value) VALUES
('zh', 'auth.email', '邮箱'), ('zh', 'auth.email', '邮箱'),
@@ -32,6 +32,7 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('zh', 'clients.never', '从不'), ('zh', 'clients.never', '从不'),
('zh', 'clients.never_expires', '永不过期'), ('zh', 'clients.never_expires', '永不过期'),
('zh', 'clients.no_clients', '还没有客户'), ('zh', 'clients.no_clients', '还没有客户'),
('zh', 'clients.overlimit', '超出限制'),
('zh', 'clients.qr_code', '二维码'), ('zh', 'clients.qr_code', '二维码'),
('zh', 'clients.received', '已接收'), ('zh', 'clients.received', '已接收'),
('zh', 'clients.restore', '恢复'), ('zh', 'clients.restore', '恢复'),
@@ -43,6 +44,8 @@ INSERT INTO translations (language_code, translation_key, translation_value) VAL
('zh', 'clients.sync_stats', '同步统计'), ('zh', 'clients.sync_stats', '同步统计'),
('zh', 'clients.title', '客户端'), ('zh', 'clients.title', '客户端'),
('zh', 'clients.traffic', '流量'), ('zh', 'clients.traffic', '流量'),
('zh', 'clients.traffic_limit', '流量限制'),
('zh', 'clients.unlimited', '无限制'),
('zh', 'common.days', ''), ('zh', 'common.days', ''),
('zh', 'dashboard.active_clients', '活跃客户端'), ('zh', 'dashboard.active_clients', '活跃客户端'),
('zh', 'dashboard.add_first_server', '添加第一个服务器'), ('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']; $serverId = (int)$params['id'];
$clientName = trim($_POST['name'] ?? ''); $clientName = trim($_POST['name'] ?? '');
$expiresInDays = !empty($_POST['expires_in_days']) ? (int)$_POST['expires_in_days'] : null; $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)) { if (empty($clientName)) {
redirect('/servers/' . $serverId . '?error=Client+name+is+required'); 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); $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); redirect('/clients/' . $clientId);
} catch (Exception $e) { } catch (Exception $e) {
redirect('/servers/' . $serverId . '?error=' . urlencode($e->getMessage())); 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 * SETTINGS ROUTES
*/ */
+40 -2
View File
@@ -28,6 +28,21 @@
<option value="365">365 {{ t('common.days') }}</option> <option value="365">365 {{ t('common.days') }}</option>
</select> </select>
</div> </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"> <button type="submit" class="gradient-bg text-white px-4 py-2 rounded w-full" id="createClientBtn">
<span id="createClientText">{{ t('form.create') }}</span> <span id="createClientText">{{ t('form.create') }}</span>
<i class="fas fa-spinner fa-spin" id="createClientSpinner" style="display:none;"></i> <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.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.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') }}</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.last_handshake') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th>
</tr> </tr>
@@ -105,12 +121,34 @@
</td> </td>
<td class="px-6 py-4 text-sm"> <td class="px-6 py-4 text-sm">
<div class="text-gray-600"> <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>
<div class="text-gray-600"> <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> </div>
</td> </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"> <td class="px-6 py-4 text-sm">
{% if client.last_handshake %} {% if client.last_handshake %}
<span class="text-gray-600">{{ client.last_handshake }}</span> <span class="text-gray-600">{{ client.last_handshake }}</span>