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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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', 'Добавить первый сервер'),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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', '添加第一个服务器'),
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user