diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2637f98 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Environment Configuration +APP_ENV=local +APP_NAME="Amnezia VPN Panel" +SESSION_NAME=amnezia_panel_session + +# Default locale +DEFAULT_LOCALE=en + +# Database Configuration +DB_HOST=db +DB_PORT=3306 +DB_DATABASE=amnezia_panel +DB_USERNAME=amnezia +DB_PASSWORD=amnezia +DB_ROOT_PASSWORD=rootpassword + +# Admin Account (auto-created on first run) +ADMIN_EMAIL=admin@amnez.ia +ADMIN_PASSWORD=admin123 + +# API Configuration +JWT_SECRET=change_this_to_random_secret_key_for_production +API_RATE_LIMIT=100 + +# VPN Defaults +DEFAULT_VPN_PORT_MIN=30000 +DEFAULT_VPN_PORT_MAX=65000 +DEFAULT_VPN_SUBNET=10.8.1.0/24 +DEFAULT_DNS=1.1.1.1,1.0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbdde6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Environment +.env +.env.local +.env.production + +# OS files +.DS_Store +Thumbs.db + +# Dependencies +vendor/ +composer.lock +node_modules/ + +# Logs +*.log +logs/ +*.tmp + +# Database +db_data/ + +# Test files +test_qr.png +test_qr.svg +/tmp/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Build artifacts +dist/ +build/ + +# Backups +*.bak +backup/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8a2891 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,135 @@ +# Changelog + +All notable changes to Amnezia VPN Web Panel will be documented in this file. + +## [1.1.0] - 2025-11-06 + +### Added +- **Traffic Statistics Tracking** + - Real-time bandwidth monitoring (upload/download) + - Last handshake tracking + - Online/offline status detection + - Formatted stats display (B, KB, MB, GB, TB) + - Manual sync stats button on server and client pages + - Batch stats sync for all clients on server + +- **Client Access Control** + - Revoke client access (temporary disable) + - Restore revoked clients + - Improved delete with proper cleanup + - Status badges (Active/Disabled) + +- **Enhanced UI** + - Traffic columns in client list table + - Last seen timestamp display + - Revoke/Restore/Delete action buttons + - Real-time stats refresh (AJAX) + - Online status indicator (green dot) + - Improved client view page with stats panel + +- **API Endpoints** + - `GET /api/clients/{id}` - Get client with stats + - `POST /api/clients/{id}/revoke` - Revoke client access + - `POST /api/clients/{id}/restore` - Restore client access + - `GET /api/servers/{id}/clients` - Get all clients with stats + - `POST /servers/{id}/sync-stats` - Batch sync server clients + - `POST /clients/{id}/sync-stats` - Sync single client stats + +### Technical +- Database migration `002_add_traffic_stats.sql` +- New columns: `bytes_sent`, `bytes_received`, `last_handshake`, `last_sync_at` +- WireGuard stats parsing from `wg show` output +- Peer removal from wg0.conf using sed +- `wg syncconf` for live config updates + +### Documentation +- Created `TRAFFIC_STATS.md` - Complete traffic stats guide +- API usage examples +- Troubleshooting section + +## [1.0.0] - 2024-11-05 + +### Added +- Initial release of Amnezia VPN Web Management Panel +- Full VPN server deployment via SSH +- AmneziaWG container management on remote servers +- Client configuration creation and management +- QR code generation compatible with Amnezia VPN apps +- User authentication system (login/register/logout) +- Role-based access control (admin/user) +- Modern responsive UI with Tailwind CSS +- Dashboard with server and client overview +- Server CRUD operations (Create, Read, Update, Delete) +- Client management with download and QR code features +- Docker Compose deployment setup +- Database migrations system +- Twig template engine integration +- REST API foundation for future Telegram bot integration + +### Technical Details +- PHP 8.2 backend +- MySQL 8.0 database +- Endroid QR Code library v5.x integration +- Qt/QDataStream compatible QR encoding (tested with Amnezia apps) +- AWG obfuscation parameters support (Jc, Jmin, Jmax, S1, S2, H1-H4) +- Secure password hashing with bcrypt +- PDO prepared statements for SQL injection prevention +- XSS protection via Twig auto-escaping + +### Security +- Default admin account: admin@amnez.ia / admin123 (change immediately!) +- Bcrypt password hashing +- SQL injection prevention +- XSS protection +- Session-based authentication + +### Known Issues +- QR code library updated to v5.x with API compatibility fixes +- Server deletion not yet removing Docker containers from remote servers +- Client deletion not yet updating server wg0.conf file +- API authentication (JWT) not yet implemented +- Rate limiting not yet implemented + +### Infrastructure +- Docker container with PHP 8.2 Apache +- MySQL 8.0 container +- Docker Compose orchestration +- Volume persistence for database +- Composer dependency management + +## [Unreleased] + +### Planned Features +- JWT authentication for REST API +- Complete Telegram bot integration +- Server monitoring and health checks +- Bandwidth usage statistics +- Client traffic analysis +- Email notifications +- Two-factor authentication (2FA) +- Multi-language support +- Dark mode UI theme +- Automated backups +- Rate limiting for API endpoints +- Export/import configurations +- Server templates for quick deployment +- Client groups and tagging +- Advanced logging and audit trail +- User management admin panel + +### Improvements Planned +- Better error handling and user feedback +- Real-time deployment progress updates +- Server resource monitoring (CPU, RAM, bandwidth) +- Client connection status tracking +- Automatic Let's Encrypt SSL setup +- Database connection pooling +- Caching layer (Redis) +- WebSocket support for real-time updates +- Mobile-responsive improvements +- Accessibility enhancements + +--- + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..c208d13 --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,691 @@ +# Developer Guide + +Guide for developers contributing to Amnezia VPN Web Panel. + +## Development Setup + +### Local Development (without Docker) + +1. **Install PHP 8.2+** +```bash +# Ubuntu/Debian +sudo apt install php8.2 php8.2-cli php8.2-mysql php8.2-gd php8.2-curl php8.2-mbstring + +# macOS (Homebrew) +brew install php@8.2 +``` + +2. **Install MySQL 8.0** +```bash +# Ubuntu/Debian +sudo apt install mysql-server-8.0 + +# macOS +brew install mysql@8.0 +``` + +3. **Install Composer** +```bash +curl -sS https://getcomposer.org/installer | php +sudo mv composer.phar /usr/local/bin/composer +``` + +4. **Clone and Setup** +```bash +git clone +cd amnezia-web-panel +composer install +``` + +5. **Configure Database** +```bash +mysql -u root -p + +CREATE DATABASE amnezia_panel CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'amnezia'@'localhost' IDENTIFIED BY 'amnezia123'; +GRANT ALL PRIVILEGES ON amnezia_panel.* TO 'amnezia'@'localhost'; +FLUSH PRIVILEGES; + +USE amnezia_panel; +SOURCE migrations/001_init.sql; +``` + +6. **Update Database Config** + +Edit `inc/DB.php`: +```php +private static $config = [ + 'host' => 'localhost', // Change from 'db' + 'dbname' => 'amnezia_panel', + 'user' => 'amnezia', + 'password' => 'amnezia123', + 'charset' => 'utf8mb4', +]; +``` + +7. **Run Development Server** +```bash +cd public +php -S localhost:8000 +``` + +Access: `http://localhost:8000` + +### Docker Development (Recommended) + +```bash +docker compose up -d +``` + +Access: `http://localhost:8082` + +**Live code editing**: Mount project as volume (already configured in docker-compose.yml) + +## Project Architecture + +### MVC Pattern + +``` +Request → Router → Controller Logic → Model → Database + ↓ + View (Twig) → Response +``` + +### Core Components + +#### 1. Router (`inc/Router.php`) + +Simple pattern-matching router: + +```php +Router::get('/path/{param}', function($params) { + // Handler logic + echo $params['param']; +}); + +Router::post('/form', function() { + // Handle POST + $data = $_POST['field']; +}); +``` + +#### 2. Database (`inc/DB.php`) + +Singleton PDO connection: + +```php +$pdo = DB::conn(); +$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); +$stmt->execute([$id]); +$user = $stmt->fetch(); +``` + +#### 3. Authentication (`inc/Auth.php`) + +Session-based auth: + +```php +// Login +Auth::login($email, $password); + +// Get current user +$user = Auth::user(); + +// Check admin +if (Auth::isAdmin()) { + // Admin logic +} + +// Logout +Auth::logout(); + +// Middleware +requireAuth(); // In route handler +``` + +#### 4. Views (`inc/View.php`) + +Twig template rendering: + +```php +View::render('template.twig', [ + 'var1' => 'value1', + 'var2' => 'value2', +]); +``` + +#### 5. Models + +**VpnServer** (`inc/VpnServer.php`): +```php +// Create and deploy server +$serverId = VpnServer::create($userId, $name, $host, $port, $username, $password); + +// Get server instance +$server = new VpnServer($serverId); +$data = $server->getData(); + +// Deploy to remote server +$server->deploy(); + +// List servers +$servers = VpnServer::listAll(); +$userServers = VpnServer::listByUser($userId); +``` + +**VpnClient** (`inc/VpnClient.php`): +```php +// Create client +$clientId = VpnClient::create($serverId, $userId, $name); + +// Get client instance +$client = new VpnClient($clientId); +$config = $client->getConfig(); +$qrCode = $client->getQRCode(); + +// List clients +$clients = VpnClient::listByServer($serverId); +$userClients = VpnClient::listByUser($userId); +``` + +#### 6. QR Code Utility (`inc/QrUtil.php`) + +Amnezia-compatible QR encoding: + +```php +require_once 'inc/QrUtil.php'; + +// From WireGuard config text +$payload = QrUtil::encodeOldPayloadFromConf($configText); + +// Generate PNG data URI +$qrImage = QrUtil::pngBase64($payload); + +// Use in template +echo ''; +``` + +## Adding New Features + +### Example: Add Server Statistics + +**1. Add database column** + +Create migration `migrations/002_add_stats.sql`: +```sql +ALTER TABLE vpn_servers ADD COLUMN stats_json TEXT; +``` + +**2. Add method to model** + +Edit `inc/VpnServer.php`: +```php +public function getStats(): array { + if (!$this->data['stats_json']) { + return []; + } + return json_decode($this->data['stats_json'], true); +} + +public function updateStats(): void { + $stats = $this->collectStatsFromServer(); + + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_servers SET stats_json = ? WHERE id = ?'); + $stmt->execute([json_encode($stats), $this->serverId]); +} + +private function collectStatsFromServer(): array { + // SSH to server, get stats + // ... + return ['cpu' => 45, 'memory' => 60, 'bandwidth' => 1024]; +} +``` + +**3. Add route** + +Edit `public/index.php`: +```php +Router::get('/servers/{id}/stats', function($params) { + requireAuth(); + $serverId = (int)$params['id']; + + $server = new VpnServer($serverId); + $stats = $server->getStats(); + + header('Content-Type: application/json'); + echo json_encode($stats); +}); +``` + +**4. Add template** + +Create `templates/servers/stats.twig`: +```twig +{% extends "layout.twig" %} + +{% block content %} +
+

Server Statistics

+ +
+
+

CPU Usage

+

{{ stats.cpu }}%

+
+ +
+
+{% endblock %} +``` + +**5. Update navigation** + +Edit `templates/layout.twig`: +```twig +Statistics +``` + +## Code Style Guidelines + +### PHP + +Follow PSR-12 coding standard: + +```php +property = $param; + } + + public function method(int $arg): bool + { + if ($arg > 0) { + return true; + } + + return false; + } +} +``` + +### SQL + +```sql +-- Use uppercase keywords +SELECT id, name, created_at +FROM vpn_servers +WHERE status = 'active' +ORDER BY created_at DESC; + +-- Prepared statements always +$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?'); +$stmt->execute([$email]); +``` + +### JavaScript + +```javascript +// Use modern ES6+ +const fetchData = async () => { + try { + const response = await fetch('/api/servers'); + const data = await response.json(); + console.log(data); + } catch (error) { + console.error('Error:', error); + } +}; + +// Event listeners +document.getElementById('btn').addEventListener('click', () => { + fetchData(); +}); +``` + +### Twig + +```twig +{# Comments #} + +{# Variables #} +{{ variable }} +{{ object.property }} +{{ array[0] }} + +{# Control structures #} +{% if condition %} + Content +{% endif %} + +{% for item in items %} + {{ item.name }} +{% endfor %} + +{# Filters #} +{{ text|upper }} +{{ html|raw }} {# Careful with XSS! #} +``` + +## Security Best Practices + +### 1. SQL Injection Prevention + +❌ **Never do this:** +```php +$sql = "SELECT * FROM users WHERE email = '$email'"; +``` + +✅ **Always use prepared statements:** +```php +$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?'); +$stmt->execute([$email]); +``` + +### 2. XSS Prevention + +❌ **Never output unescaped:** +```php +echo $_GET['name']; // Dangerous! +``` + +✅ **Escape output:** +```php +echo htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8'); +``` + +In Twig (auto-escapes by default): +```twig +{{ user_input }} {# Safe #} +{{ user_input|raw }} {# Unsafe - use carefully #} +``` + +### 3. CSRF Protection + +TODO: Implement token-based CSRF protection: + +```php +// Generate token +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + +// In form + + +// Verify +if ($_POST['csrf_token'] !== $_SESSION['csrf_token']) { + die('CSRF token mismatch'); +} +``` + +### 4. Password Hashing + +✅ **Always use bcrypt:** +```php +// Hash +$hash = password_hash($password, PASSWORD_BCRYPT); + +// Verify +if (password_verify($password, $hash)) { + // Correct +} +``` + +### 5. Input Validation + +```php +// Email +if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new Exception('Invalid email'); +} + +// Integer +$id = (int)$_GET['id']; + +// String length +if (strlen($name) < 3 || strlen($name) > 50) { + throw new Exception('Invalid name length'); +} +``` + +## Testing + +### Unit Tests (TODO) + +```php +// tests/VpnServerTest.php +use PHPUnit\Framework\TestCase; + +class VpnServerTest extends TestCase +{ + public function testCreate() + { + $serverId = VpnServer::create(1, 'Test', '192.168.1.1', 22, 'root', 'pass'); + $this->assertIsInt($serverId); + $this->assertGreaterThan(0, $serverId); + } +} +``` + +Run tests: +```bash +composer require --dev phpunit/phpunit +./vendor/bin/phpunit tests/ +``` + +### Manual Testing + +See [TESTING.md](TESTING.md) for comprehensive testing guide. + +## Debugging + +### Enable Error Display + +In development, edit `public/index.php`: + +```php +ini_set('display_errors', 1); +ini_set('display_startup_errors', 1); +error_reporting(E_ALL); +``` + +### Database Queries + +```php +// Enable query logging +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +try { + $stmt = $pdo->prepare($sql); + $stmt->execute($params); +} catch (PDOException $e) { + error_log("SQL Error: " . $e->getMessage()); + error_log("Query: $sql"); + error_log("Params: " . print_r($params, true)); + throw $e; +} +``` + +### SSH Commands + +```php +// Add debug output +$cmd = "your command"; +error_log("Executing SSH command: $cmd"); +$output = shell_exec($sshCmd); +error_log("SSH output: $output"); +``` + +### Docker Logs + +```bash +# Web container logs +docker compose logs -f web + +# Database logs +docker compose logs -f db + +# Last 100 lines +docker compose logs --tail=100 web +``` + +## API Development + +### Adding New Endpoint + +```php +// In public/index.php + +Router::post('/api/clients', function() { + // TODO: Verify JWT token + + header('Content-Type: application/json'); + + try { + $serverId = (int)$_POST['server_id']; + $name = trim($_POST['name'] ?? ''); + + if (!$serverId || !$name) { + http_response_code(400); + echo json_encode(['error' => 'Missing parameters']); + return; + } + + $user = Auth::user(); + $clientId = VpnClient::create($serverId, $user['id'], $name); + + $client = new VpnClient($clientId); + + echo json_encode([ + 'success' => true, + 'client' => $client->getData(), + ]); + + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); +``` + +### JWT Authentication (TODO) + +```php +use Firebase\JWT\JWT; + +// Generate token +$payload = [ + 'user_id' => $user['id'], + 'exp' => time() + 3600, // 1 hour +]; +$token = JWT::encode($payload, $secretKey, 'HS256'); + +// Verify token +try { + $decoded = JWT::decode($token, $secretKey, ['HS256']); + $userId = $decoded->user_id; +} catch (Exception $e) { + http_response_code(401); + echo json_encode(['error' => 'Invalid token']); + exit; +} +``` + +## Deployment + +### Production Checklist + +- [ ] Change default admin password +- [ ] Update database passwords in docker-compose.yml +- [ ] Set up HTTPS (nginx reverse proxy + Let's Encrypt) +- [ ] Disable error display +- [ ] Enable error logging +- [ ] Set up automated backups +- [ ] Configure firewall +- [ ] Set up monitoring +- [ ] Review security settings +- [ ] Test disaster recovery + +### Environment Variables + +Create `.env.production`: +```env +DB_HOST=db +DB_NAME=amnezia_panel +DB_USER=amnezia +DB_PASS=strong_random_password_here +JWT_SECRET=another_strong_random_secret_here +ADMIN_EMAIL=admin@yourdomain.com +``` + +Load in PHP: +```php +$dotenv = Dotenv\Dotenv::createImmutable(__DIR__); +$dotenv->load(); + +$dbPassword = $_ENV['DB_PASS']; +``` + +## Contributing + +1. Fork repository +2. Create feature branch: `git checkout -b feature/my-feature` +3. Make changes +4. Write tests +5. Commit: `git commit -am 'Add my feature'` +6. Push: `git push origin feature/my-feature` +7. Create Pull Request + +### Commit Message Format + +``` +type: subject + +body (optional) + +footer (optional) +``` + +Types: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation +- `style`: Formatting +- `refactor`: Code restructuring +- `test`: Tests +- `chore`: Maintenance + +Example: +``` +feat: add server statistics dashboard + +- Added stats collection via SSH +- Created stats API endpoint +- Built statistics template +- Updated navigation + +Closes #123 +``` + +## Resources + +- [PHP Documentation](https://www.php.net/docs.php) +- [MySQL Reference](https://dev.mysql.com/doc/) +- [Twig Documentation](https://twig.symfony.com/doc/) +- [Tailwind CSS](https://tailwindcss.com/docs) +- [Docker Documentation](https://docs.docker.com/) +- [WireGuard Protocol](https://www.wireguard.com/) +- [Amnezia VPN GitHub](https://github.com/amnezia-vpn/amnezia-client) + +--- + +Happy coding! 🚀 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2225c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM php:8.2-apache + +# Install dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + libpng-dev \ + libonig-dev \ + libxml2-dev \ + zip \ + unzip \ + sshpass \ + openssh-client \ + qrencode \ + && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd \ + && a2enmod rewrite \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /var/www/html + +# Copy project files +COPY . /var/www/html + +# Install PHP dependencies +RUN composer install --no-dev --optimize-autoloader + +# Configure Apache +COPY apache.conf /etc/apache2/sites-available/000-default.conf + +# Set permissions +RUN chown -R www-data:www-data /var/www/html \ + && chmod -R 755 /var/www/html/public + +# Expose port 80 +EXPOSE 80 + +CMD ["apache2-foreground"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ddbb92c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Amnezia VPN Community + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..f03aa1a --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,583 @@ +# Project Structure + +Complete file structure of Amnezia VPN Web Panel with descriptions. + +``` +amnezia-web-panel/ +│ +├── 📄 README.md # Main project documentation +├── 📄 CHANGELOG.md # Version history and changes +├── 📄 LICENSE # MIT License +├── 📄 TESTING.md # Testing guide +├── 📄 DEVELOPER.md # Developer documentation +├── 📄 .gitignore # Git ignore rules +├── 📄 .env.example # Environment template +├── 📄 .env # Environment variables (not in git) +│ +├── 🐳 Docker Files +│ ├── docker-compose.yml # Docker orchestration +│ ├── Dockerfile # PHP 8.2 Apache image +│ └── apache.conf # Apache configuration +│ +├── 📦 Dependencies +│ ├── composer.json # PHP dependencies +│ └── composer.lock # Locked versions (generated) +│ +├── 💾 Database +│ └── migrations/ +│ └── 001_init.sql # Initial schema (users, servers, clients, etc.) +│ +├── 🎨 Frontend (Public) +│ └── public/ +│ ├── index.php # Main entry point & router +│ └── .htaccess # Apache URL rewriting +│ +├── 🧩 Backend (Core Classes) +│ └── inc/ +│ ├── Router.php # URL routing system +│ ├── DB.php # Database connection (PDO) +│ ├── Auth.php # Authentication & sessions +│ ├── View.php # Twig template rendering +│ ├── Config.php # Configuration loader +│ ├── VpnServer.php # Server management & deployment +│ ├── VpnClient.php # Client config & QR generation +│ └── QrUtil.php # Amnezia QR encoding utility +│ +├── 🖼️ Templates (Views) +│ └── templates/ +│ ├── layout.twig # Base layout (header, nav, footer) +│ ├── login.twig # Login page +│ ├── register.twig # Registration page +│ ├── dashboard.twig # User dashboard +│ ├── servers/ +│ │ ├── index.twig # Server list +│ │ ├── create.twig # Add server form +│ │ ├── deploy.twig # Deployment progress +│ │ └── view.twig # Server details & client management +│ └── clients/ +│ └── view.twig # Client config & QR code +│ +└── 🧪 Testing + ├── test_qr.php # QR code generation test + └── test_qr.png # Generated test QR (not in git) +``` + +## File Descriptions + +### Root Configuration Files + +#### `README.md` +Main project documentation with: +- Feature overview +- Quick start guide +- Installation instructions +- Usage examples +- Technology stack +- Contributing guidelines + +#### `CHANGELOG.md` +Version history following [Keep a Changelog](https://keepachangelog.com/) format: +- v1.0.0 initial release features +- Known issues +- Planned features + +#### `LICENSE` +MIT License - open source, commercial use allowed. + +#### `TESTING.md` +Comprehensive testing guide: +- Unit tests +- Integration tests +- Security tests +- Browser compatibility +- Troubleshooting + +#### `DEVELOPER.md` +Developer documentation: +- Development setup +- Architecture overview +- Code style guidelines +- Security best practices +- API development +- Contribution guide + +#### `.gitignore` +Git exclusions: +- Environment files (.env) +- Dependencies (vendor/) +- Database data (db_data/) +- OS files (.DS_Store) +- Logs (*.log) +- IDE configs + +#### `.env.example` +Environment template: +```env +MYSQL_ROOT_PASSWORD=root123 +MYSQL_DATABASE=amnezia_panel +MYSQL_USER=amnezia +MYSQL_PASSWORD=amnezia123 +``` + +### Docker Files + +#### `docker-compose.yml` +Two services: +- **web**: PHP 8.2 Apache container + - Mounts project directory + - Exposes port 8082 + - Depends on database +- **db**: MySQL 8.0 container + - Persistent volume (db_data/) + - Runs init migrations + +#### `Dockerfile` +PHP 8.2 Apache image with: +- PHP extensions: pdo_mysql, gd, sodium, curl +- Composer installed +- sshpass for SSH deployment +- Apache mod_rewrite enabled + +#### `apache.conf` +Virtual host configuration: +- Document root: /var/www/html/public +- AllowOverride All for .htaccess +- Directory permissions + +### Database + +#### `migrations/001_init.sql` +Initial schema: + +**Tables**: +1. `users` - User accounts (id, name, email, password, role, created_at) +2. `vpn_servers` - VPN servers (id, user_id, name, host, port, status, keys, AWG params, etc.) +3. `vpn_clients` - VPN clients (id, server_id, user_id, name, IP, keys, config, QR code, etc.) +4. `api_tokens` - API authentication (id, user_id, token, expires_at) +5. `settings` - Application settings (key-value store) + +**Indexes**: +- Email uniqueness +- Server-client relationships +- Status filtering + +**Default Data**: +- Admin user: admin@amnez.ia / admin123 (bcrypt hashed) + +### Frontend (Public) + +#### `public/index.php` +Main application entry point: +- Autoloader (Composer) +- Error handling +- Route definitions: + - `/` - Home (redirect to dashboard) + - `/login` - Login page + - `/register` - Registration page + - `/logout` - Logout action + - `/dashboard` - User dashboard + - `/servers` - Server list + - `/servers/create` - Add server + - `/servers/{id}` - Server details + - `/servers/{id}/clients/create` - Create client + - `/clients/{id}` - Client details + - `/clients/{id}/download` - Download config + - `/clients/{id}/delete` - Delete client + - API routes (future) + +#### `public/.htaccess` +Apache URL rewriting: +- Route all requests to index.php +- Preserve query strings +- Allow static files + +### Backend (Core) + +#### `inc/Router.php` +Simple pattern-matching router: +- `Router::get($path, $handler)` - GET routes +- `Router::post($path, $handler)` - POST routes +- Pattern variables: `/path/{id}` +- 404 handling + +#### `inc/DB.php` +Database singleton: +- `DB::conn()` - Get PDO connection +- MySQL configuration +- UTF8MB4 charset +- Exception mode + +#### `inc/Auth.php` +Authentication system: +- `Auth::login($email, $password)` - Authenticate user +- `Auth::logout()` - Clear session +- `Auth::user()` - Get current user +- `Auth::isLoggedIn()` - Check if logged in +- `Auth::isAdmin()` - Check admin role +- Bcrypt password hashing + +#### `inc/View.php` +Template rendering: +- `View::render($template, $data)` - Render Twig template +- Template caching +- Auto-escaping enabled +- Global variables (user, isAdmin) + +#### `inc/Config.php` +Configuration loader: +- Database settings +- Application settings +- Environment-based config + +#### `inc/VpnServer.php` +Server management: +- `VpnServer::create(...)` - Create server record +- `$server->deploy()` - Deploy to remote server via SSH: + - Install Docker + - Create AWG container + - Generate server keys + - Configure firewall + - Start VPN service +- `$server->getData()` - Get server info +- `VpnServer::listAll()` - List all servers +- `VpnServer::listByUser($userId)` - User's servers + +**Deployment Steps**: +1. Connect via SSH (sshpass) +2. Check/install Docker +3. Create AWG container from image +4. Generate WireGuard keys (private, public, preshared) +5. Generate AWG obfuscation params (Jc, Jmin, Jmax, S1, S2, H1-H4) +6. Create wg0.conf configuration +7. Start WireGuard interface +8. Configure iptables NAT +9. Enable IP forwarding +10. Open firewall port + +#### `inc/VpnClient.php` +Client management: +- `VpnClient::create($serverId, $userId, $name)` - Create client: + - Generate client keys + - Assign IP from subnet + - Build WireGuard config + - Add peer to server + - Generate QR code +- `$client->getConfig()` - Get config text +- `$client->getQRCode()` - Get QR code PNG data URI +- `VpnClient::listByServer($serverId)` - Server's clients +- `VpnClient::listByUser($userId)` - User's clients + +#### `inc/QrUtil.php` +**Critical: Amnezia-compatible QR encoding** + +From `/Users/oleg/Documents/amnezia/QrUtil.php` (tested, working format): + +Methods: +- `QrUtil::encodeOldPayloadFromConf($config)` - Encode config to Amnezia format: + - Parse WireGuard config + - Build JSON envelope with AWG params + - Compress with gzcompress + - Add Qt/QDataStream headers + - URL-safe Base64 encode +- `QrUtil::pngBase64($payload)` - Generate QR code PNG: + - Uses Endroid\QrCode library v5.x + - Returns data URI: `data:image/png;base64,...` + - Fallback to SVG if GD not available + +**Format Details**: +- Header: Version (0x07C00100), compressed length, uncompressed length +- Payload: gzcompress(JSON, level 9) +- Encoding: URL-safe Base64 (+ → -, / → _, = trimmed) +- Structure: Qt QDataStream compatible + +### Templates + +#### `templates/layout.twig` +Base layout: +- HTML5 structure +- Tailwind CSS CDN +- Font Awesome icons +- Navigation menu +- User info (if logged in) +- Logout link +- Content block + +#### `templates/login.twig` +Login form: +- Email input +- Password input +- Error display +- Link to register + +#### `templates/register.twig` +Registration form: +- Name input +- Email input +- Password input +- Success/error display + +#### `templates/dashboard.twig` +User dashboard: +- Servers overview (card grid) +- Clients overview (table) +- Quick actions +- Statistics (future) + +#### `templates/servers/index.twig` +Server list: +- Table view +- Status badges +- Actions (view, edit, delete) +- Add server button + +#### `templates/servers/create.twig` +Add server form: +- Server details (name, host, port) +- SSH credentials (username, password) +- Validation + +#### `templates/servers/deploy.twig` +Deployment progress: +- Real-time log updates +- Progress indicator +- Success/error status +- Redirect to server view + +#### `templates/servers/view.twig` +Server details: +- Server info (status, port, subnet) +- Create client form +- Client list table +- Actions (download config, view QR) + +#### `templates/clients/view.twig` +Client details: +- Client info (IP, created date) +- QR code image +- Download button +- Delete button + +### Testing + +#### `test_qr.php` +QR code generation test: +- Sample WireGuard config +- Generate payload +- Generate QR PNG +- Save to file +- Verify output + +**Usage**: +```bash +docker compose exec web php test_qr.php +``` + +**Expected Output**: +``` +✅ Success! QR code generation working correctly. +✅ QR code saved to: /var/www/html/test_qr.png +``` + +## Data Flow + +### Server Deployment Flow + +``` +User submits form + ↓ +Router: POST /servers/create + ↓ +VpnServer::create() - Insert to DB + ↓ +Redirect to /servers/{id}/deploy + ↓ +VpnServer->deploy() + ↓ +SSH to remote server + ↓ +Execute deployment commands: + - Install Docker + - Pull AWG image + - Generate keys + - Create config + - Start container + ↓ +Update DB with server details + ↓ +Redirect to /servers/{id} +``` + +### Client Creation Flow + +``` +User submits client name + ↓ +Router: POST /servers/{id}/clients/create + ↓ +VpnClient::create($serverId, $userId, $name) + ↓ +Steps: + 1. Get server data + 2. Generate client keys (SSH exec) + 3. Get next free IP + 4. Build config text + 5. Add peer to server (append wg0.conf, wg syncconf) + 6. Generate QR code (QrUtil) + 7. Insert to DB + ↓ +Redirect to /clients/{id} + ↓ +Display config + QR code +``` + +### QR Code Generation Flow + +``` +WireGuard config text + ↓ +QrUtil::encodeOldPayloadFromConf($config) + ↓ +Parse config (regex): + - Interface params + - Peer params + - AWG params (H1-H4, Jc, Jmin, Jmax, S1, S2) + ↓ +Build JSON envelope: + - containers[] + - awg (params) + - container: "amnezia-awg" + - defaultContainer + - description + - dns1, dns2 + - hostName + ↓ +JSON encode (pretty print) + ↓ +gzcompress(JSON, level 9) + ↓ +Add header: pack('N3', version, compLen, uncompLen) + ↓ +URL-safe Base64 encode + ↓ +QrUtil::pngBase64($payload) + ↓ +Generate QR with Endroid\QrCode + ↓ +Return data URI: "data:image/png;base64,..." +``` + +## Dependencies + +### PHP (Composer) + +```json +{ + "require": { + "php": ">=8.0", + "twig/twig": "^3.8", // Template engine + "endroid/qr-code": "^5.0", // QR code generation + "ext-pdo": "*", // Database + "ext-json": "*", // JSON encoding + "ext-curl": "*", // HTTP requests + "ext-gd": "*", // Image processing + "ext-sodium": "*" // Crypto (key derivation) + } +} +``` + +### System (Docker) + +- **PHP 8.2**: Modern PHP with types, enums, attributes +- **Apache 2.4**: Web server with mod_rewrite +- **MySQL 8.0**: Relational database +- **sshpass**: Non-interactive SSH password auth +- **Docker CLI**: Container management (on remote servers) + +## Security Considerations + +### Implemented + +✅ Password hashing (bcrypt) +✅ SQL injection prevention (prepared statements) +✅ XSS prevention (Twig auto-escape) +✅ Session-based authentication +✅ Role-based access control + +### TODO + +⚠️ CSRF protection (tokens) +⚠️ Rate limiting (API) +⚠️ JWT authentication (API) +⚠️ Input sanitization (comprehensive) +⚠️ HTTPS enforcement +⚠️ Security headers (CSP, HSTS, etc.) + +## Performance + +### Optimizations + +- Singleton DB connection +- Template caching (Twig) +- Lazy loading (models) +- Indexed database queries + +### Future + +- Redis caching +- Database connection pooling +- CDN for static assets +- Minified CSS/JS +- Gzip compression + +## Monitoring + +### Logs + +- Apache access logs: `/var/log/apache2/access.log` +- Apache error logs: `/var/log/apache2/error.log` +- PHP error logs: `error_log()` function +- MySQL slow query log + +### Health Checks + +```bash +# Container status +docker compose ps + +# Application health +curl http://localhost:8082/ + +# Database health +docker compose exec db mysql -u amnezia -p -e "SELECT 1" +``` + +## Backup & Recovery + +### Database Backup + +```bash +# Backup +docker compose exec db mysqldump -u amnezia -pamnezia123 amnezia_panel > backup.sql + +# Restore +docker compose exec -T db mysql -u amnezia -pamnezia123 amnezia_panel < backup.sql +``` + +### Full Backup + +```bash +# Backup everything +tar -czf amnezia-backup-$(date +%Y%m%d).tar.gz \ + --exclude=vendor \ + --exclude=db_data \ + amnezia-web-panel/ + +# Also backup database +docker compose exec db mysqldump -u amnezia -pamnezia123 amnezia_panel > db-backup-$(date +%Y%m%d).sql +``` + +--- + +**Last Updated**: 2024-11-05 +**Version**: 1.0.0 +**Maintainer**: Amnezia VPN Community diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..0819554 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,345 @@ +# Testing Guide + +This document describes how to test the Amnezia VPN Web Panel. + +## Prerequisites + +- Docker and Docker Compose installed +- Test VPS server with SSH access (for full deployment testing) +- Amnezia VPN mobile app (Android/iOS) for QR code testing + +## Quick Test Setup + +### 1. Start the Application + +```bash +cd amnezia-web-panel +docker compose up -d +``` + +### 2. Access the Panel + +Open browser: `http://localhost:8082` + +### 3. Login + +Default credentials: +- Email: `admin@amnez.ia` +- Password: `admin123` + +## Unit Tests + +### Test QR Code Generation + +```bash +docker compose exec web php test_qr.php +``` + +Expected output: +``` +✅ Success! QR code generation working correctly. +``` + +This creates `test_qr.png` in the project root. + +### Verify QR Code Payload + +```bash +# Compare payload with original implementation +php /tmp/test_compare_qr.php +``` + +The payload should match exactly with the original Amnezia QR format. + +## Integration Tests + +### Test 1: User Registration + +1. Logout from admin account +2. Click "Register" +3. Fill in: + - Name: "Test User" + - Email: "test@example.com" + - Password: "testpass123" +4. Click "Register" +5. ✅ Should redirect to dashboard + +### Test 2: Server Creation (Without Deployment) + +1. Go to "Servers" → "Add Server" +2. Fill in: + - Name: "Test Server" + - Host: "192.168.1.100" + - Port: 22 + - Username: "root" + - Password: "dummy" +3. Click "Add Server" (will fail at deployment, but server record created) +4. ✅ Should see server in list with "pending" status + +### Test 3: Full Server Deployment (Requires Real VPS) + +**Prerequisites**: Remote Linux server with SSH access + +1. Go to "Servers" → "Add Server" +2. Fill in real server credentials: + - Name: "Production Server 1" + - Host: "your.server.ip" + - Port: 22 + - Username: "root" + - Password: "your_ssh_password" +3. Click "Add Server" +4. Wait for deployment (5-10 minutes) +5. ✅ Server status should change to "active" +6. ✅ Server should show public key and VPN port + +### Test 4: Client Creation + +**Prerequisites**: Active server from Test 3 + +1. Click on active server +2. In "Create Client" section, enter name: "test-client-1" +3. Click "Create" +4. ✅ Should redirect to client view page +5. ✅ Should see QR code displayed +6. ✅ "Download Config" button should work + +### Test 5: QR Code Scanning + +**Prerequisites**: Amnezia VPN app installed on phone + +1. Create a client (Test 4) +2. Open Amnezia VPN app +3. Tap "Add server" → "Scan QR code" +4. Scan the QR code from web panel +5. ✅ Configuration should be imported successfully +6. ✅ Connect to VPN should work +7. ✅ Check IP address changed (e.g., whatismyip.com) + +### Test 6: Configuration Download + +1. Go to client details page +2. Click "Download Config" +3. ✅ Should download `.conf` file +4. Open file in text editor +5. ✅ Should contain valid WireGuard config with: + - [Interface] section with PrivateKey, Address, DNS + - AWG parameters (Jc, Jmin, Jmax, S1, S2, H1-H4) + - [Peer] section with PublicKey, PresharedKey, Endpoint +6. Import manually into Amnezia VPN app +7. ✅ Should work same as QR code + +### Test 7: Multiple Clients + +1. Create 5 clients on same server +2. ✅ Each should get unique IP (10.8.1.2, 10.8.1.3, etc.) +3. ✅ Each should have unique keys +4. ✅ All QR codes should scan successfully +5. Test connections from multiple devices +6. ✅ All should connect simultaneously + +### Test 8: Client Deletion + +1. Go to client details +2. Click "Delete" +3. ✅ Client should be removed from database +4. ⚠️ **Known Issue**: Not yet removed from server wg0.conf + +### Test 9: Server Deletion + +1. Go to server list +2. Click "Delete" on a server +3. ✅ Server should be removed from database +4. ⚠️ **Known Issue**: Docker container not removed from remote server + +### Test 10: Access Control + +1. Create new user account +2. Login as new user +3. Create a server +4. Logout and login as admin +5. ✅ Admin should see all servers (including user's) +6. Login as regular user +7. ✅ Regular user should only see their own servers + +## Security Tests + +### Test 11: SQL Injection Protection + +Try creating server with malicious name: +``` +Name: Test'; DROP TABLE vpn_servers; -- +``` + +✅ Should be safely escaped, no SQL error + +### Test 12: XSS Protection + +Try creating client with script tag: +``` +Name: +``` + +✅ Should be HTML-escaped in output + +### Test 13: Authentication + +1. Logout +2. Try accessing `/dashboard` directly +3. ✅ Should redirect to login page + +### Test 14: Password Security + +1. Check database: +```bash +docker compose exec db mysql -u amnezia -pamnezia123 amnezia_panel +SELECT password FROM users LIMIT 1; +``` + +✅ Password should be bcrypt hash, not plaintext + +## Performance Tests + +### Test 15: Multiple Concurrent Requests + +```bash +# Install Apache Bench +sudo apt install apache2-utils + +# Test login endpoint +ab -n 100 -c 10 -p login.txt -T application/x-www-form-urlencoded http://localhost:8082/login +``` + +✅ Should handle 100 requests without errors + +### Test 16: Database Connection Pooling + +Create 10 clients rapidly: +```bash +for i in {1..10}; do + curl -X POST http://localhost:8082/servers/1/clients/create \ + -d "name=client$i" \ + -b cookies.txt +done +``` + +✅ Should complete without connection errors + +## Browser Compatibility + +Test in: +- ✅ Chrome/Edge (Chromium) +- ✅ Firefox +- ✅ Safari +- ✅ Mobile browsers (iOS Safari, Chrome Android) + +## Docker Tests + +### Test 17: Container Health + +```bash +docker compose ps +``` + +✅ Both containers should be "Up" and healthy + +### Test 18: Volume Persistence + +```bash +# Stop containers +docker compose down + +# Start again +docker compose up -d + +# Login +``` + +✅ All data should persist (servers, clients, users) + +### Test 19: Logs + +```bash +docker compose logs -f web +docker compose logs -f db +``` + +✅ No errors in logs during normal operation + +## Troubleshooting + +### QR Code Not Displaying + +Check: +```bash +docker compose exec web php test_qr.php +``` + +If fails, check: +- GD extension installed: `php -m | grep gd` +- Composer dependencies: `composer show endroid/qr-code` + +### Can't Connect to Database + +Check: +```bash +docker compose exec web php -r " +\$pdo = new PDO('mysql:host=db;dbname=amnezia_panel', 'amnezia', 'amnezia123'); +echo 'Connected successfully'; +" +``` + +### SSH Deployment Fails + +Test SSH manually: +```bash +sshpass -p 'yourpassword' ssh -o StrictHostKeyChecking=no root@server.ip 'echo OK' +``` + +## Test Checklist + +Before releasing or deploying: + +- [ ] All unit tests pass +- [ ] QR code generation works +- [ ] Server deployment works on real VPS +- [ ] Client creation works +- [ ] QR codes scan in Amnezia app +- [ ] VPN connection works +- [ ] Multiple clients work simultaneously +- [ ] Authentication works +- [ ] Access control works (user/admin) +- [ ] SQL injection protected +- [ ] XSS protected +- [ ] CSRF protection (if implemented) +- [ ] Password hashing verified +- [ ] All browsers work +- [ ] Mobile responsive +- [ ] Docker containers healthy +- [ ] Data persists after restart +- [ ] No errors in logs +- [ ] README instructions accurate +- [ ] Default password changed + +## Automated Testing (Future) + +Consider implementing: +- PHPUnit for unit tests +- Selenium for browser automation +- GitHub Actions for CI/CD +- Code coverage reports +- Automated security scanning + +## Reporting Issues + +When reporting bugs, include: +1. Steps to reproduce +2. Expected behavior +3. Actual behavior +4. Docker logs: `docker compose logs` +5. Browser console errors +6. PHP version: `docker compose exec web php -v` +7. MySQL version: `docker compose exec db mysql -V` + +--- + +Happy Testing! 🧪 diff --git a/TRAFFIC_STATS.md b/TRAFFIC_STATS.md new file mode 100644 index 0000000..11d8e91 --- /dev/null +++ b/TRAFFIC_STATS.md @@ -0,0 +1,278 @@ +# Traffic Statistics & Client Management Features + +## New Features Added (2025-11-06) + +### 1. Traffic Statistics Tracking + +**Database Changes:** +- Added `bytes_sent` - Total bytes uploaded by client +- Added `bytes_received` - Total bytes downloaded by client +- Added `last_handshake` - Last successful WireGuard connection time +- Added `last_sync_at` - Last time stats were synced from server + +**Backend Methods:** +- `VpnClient->syncStats()` - Sync single client statistics from server +- `VpnClient::syncAllStatsForServer($serverId)` - Sync all clients on a server +- `VpnClient->getFormattedStats()` - Get human-readable stats (KB, MB, GB) +- `VpnClient::getClientStatsFromServer()` - Parse `wg show` output + +**How it works:** +1. Connects to server via SSH +2. Runs `wg show wg0 dump` inside Docker container +3. Parses output to extract transfer statistics +4. Updates database with latest stats +5. Calculates "last seen" based on handshake time + +### 2. Client Access Control + +**Revoke Access:** +- Temporarily disable client without deleting +- Removes peer from server WireGuard config +- Keeps client record in database with status='disabled' +- Can be restored later + +**Restore Access:** +- Re-enable previously revoked client +- Re-adds peer to server WireGuard config +- Changes status back to 'active' + +**Delete Client:** +- Permanently removes client +- First revokes access (removes from server) +- Then deletes from database + +**Backend Methods:** +- `VpnClient->revoke()` - Disable client access +- `VpnClient->restore()` - Re-enable client access +- `VpnClient->delete()` - Permanently delete client +- `VpnClient::removeClientFromServer()` - Remove peer from wg0.conf +- `VpnClient::removeFromClientsTable()` - Remove from clientsTable JSON + +### 3. Web Interface Updates + +**Server View Page (`/servers/{id}`):** +- Added "Sync Stats" button - refreshes all client stats +- Enhanced client table with: + - Status badge (Active/Disabled) + - Traffic columns (Upload/Download) + - Last seen timestamp + - Action buttons (View, Revoke/Restore, Delete) + +**Client View Page (`/clients/{id}`):** +- Added traffic statistics panel +- Shows uploaded/downloaded/total bytes +- Displays last handshake time +- "Refresh" button to sync latest stats +- Real-time status indicator (Online if handshake < 5 min) +- Revoke/Restore button based on current status + +### 4. API Endpoints + +All endpoints require authentication (session-based, JWT planned). + +**GET `/api/clients/{id}`** +```json +{ + "success": true, + "client": { + "id": 1, + "name": "client1", + "server_id": 1, + "client_ip": "10.8.1.2", + "status": "active", + "created_at": "2025-11-06 12:00:00", + "stats": { + "sent": "1.5 GB", + "received": "3.2 GB", + "total": "4.7 GB", + "last_seen": "Online", + "is_online": true + }, + "bytes_sent": 1610612736, + "bytes_received": 3435973836, + "last_handshake": "2025-11-06 14:30:00" + } +} +``` + +**POST `/api/clients/{id}/revoke`** +```json +{ + "success": true, + "message": "Client revoked" +} +``` + +**POST `/api/clients/{id}/restore`** +```json +{ + "success": true, + "message": "Client restored" +} +``` + +**GET `/api/servers/{id}/clients`** +Returns all clients with synced stats for a server. + +```json +{ + "success": true, + "clients": [ + { + "id": 1, + "name": "client1", + "client_ip": "10.8.1.2", + "status": "active", + "stats": {...}, + ... + } + ] +} +``` + +**POST `/servers/{id}/sync-stats`** +```json +{ + "success": true, + "synced": 5 +} +``` + +**POST `/clients/{id}/sync-stats`** +```json +{ + "success": true, + "stats": { + "sent": "1.5 GB", + "received": "3.2 GB", + "total": "4.7 GB", + "last_seen": "Online" + } +} +``` + +## Usage Examples + +### Web Interface + +1. **View Client Statistics:** + - Go to server page + - Click "Sync Stats" to refresh all clients + - View traffic in table or click client for details + +2. **Revoke Client Access:** + - In server client list, click "Revoke" next to active client + - Confirm action + - Client status changes to "Disabled" + - Client can no longer connect to VPN + +3. **Restore Client Access:** + - Find disabled client in list + - Click "Restore" + - Client status changes to "Active" + - Client can connect again + +### API Usage (for Telegram Bot) + +```php +// Get client with stats +$response = $api->get('/api/clients/1'); +$client = $response['client']; + +echo "Traffic: {$client['stats']['total']}\n"; +echo "Status: {$client['stats']['last_seen']}\n"; + +// Revoke access +$api->post('/api/clients/1/revoke'); + +// Restore access +$api->post('/api/clients/1/restore'); + +// Get all server clients with stats +$response = $api->get('/api/servers/1/clients'); +foreach ($response['clients'] as $client) { + echo "{$client['name']}: {$client['stats']['total']}\n"; +} +``` + +## Technical Details + +### WireGuard Stats Format + +The `wg show wg0 dump` command returns: +``` +private_key public_key preshared_key endpoint allowed_ips latest_handshake transfer_rx transfer_tx persistent_keepalive +``` + +We parse: +- `latest_handshake` - Unix timestamp of last handshake +- `transfer_rx` - Bytes received by server (client sent) +- `transfer_tx` - Bytes sent by server (client received) + +### Peer Removal + +Removing peer from `wg0.conf`: +```bash +# Find and delete [Peer] block with matching PublicKey +sed -i '/^\[Peer\]/,/^$/{/PublicKey = /,/^$/d}' /opt/amnezia/awg/wg0.conf + +# Apply changes without restart +wg syncconf wg0 <(wg-quick strip /opt/amnezia/awg/wg0.conf) +``` + +### Client Status Logic + +- **Online:** Last handshake < 5 minutes ago +- **Recently seen:** Last handshake < 1 hour ago +- **Offline:** Last handshake > 1 hour ago +- **Never connected:** No handshake recorded + +## Database Migration + +Migration file: `migrations/002_add_traffic_stats.sql` + +To apply manually: +```bash +docker compose exec -T db mysql -u root -prootpassword amnezia_panel < migrations/002_add_traffic_stats.sql +``` + +## Performance Considerations + +- Stats sync requires SSH connection to server +- Each sync runs `wg show wg0 dump` command +- For many clients, use batch sync: `VpnClient::syncAllStatsForServer()` +- Consider caching stats and refreshing periodically (e.g., every 5 minutes) +- Stats updates are logged in `last_sync_at` column + +## Future Enhancements + +- [ ] Automatic periodic stats sync (cron job) +- [ ] Traffic usage alerts (email/Telegram) +- [ ] Bandwidth limits per client +- [ ] Historical traffic graphs +- [ ] Export stats to CSV +- [ ] Real-time WebSocket updates +- [ ] Client connection notifications + +## Troubleshooting + +**Stats not syncing:** +1. Check server SSH connection +2. Verify Docker container is running: `docker ps | grep awg` +3. Check `wg show wg0` output inside container +4. Review error logs + +**Client still connecting after revoke:** +1. Check if peer was removed from wg0.conf +2. Verify `wg syncconf` was executed +3. Restart WireGuard: `docker exec wg-quick down wg0 && wg-quick up wg0` + +**Last handshake not updating:** +1. Ensure client is actually connected +2. Check WireGuard keepalive settings (should be 25 seconds) +3. Verify server time is synchronized (NTP) + +--- + +**Last Updated:** 2025-11-06 +**Version:** 1.1.0 diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md new file mode 100644 index 0000000..07dc9ef --- /dev/null +++ b/TRANSLATIONS.md @@ -0,0 +1,142 @@ +# Translation Management + +## Database Reset & Setup + +All translations have been reset and reloaded with complete English keys (79 total). + +### Migration Applied +- `migrations/006_full_translations.sql` - Complete translation reset with all 79 English keys + +## Translation Keys Summary + +### Categories: +- **Authentication** (5 keys): email, login, name, password, register +- **Clients** (17 keys): actions, add, delete, download_config, ip, etc. +- **Dashboard** (5 keys): active_clients, title, total_clients, etc. +- **Forms** (6 keys): cancel, close, loading, processing, save, submit +- **Menu** (6 keys): clients, dashboard, logout, servers, settings, users +- **Messages** (6 keys): confirm, deleted, deployed, error, saved, success +- **Servers** (12 keys): actions, add, clients, delete, deploy, etc. +- **Settings** (17 keys): api_keys, auto_translate, translations, etc. +- **Status** (5 keys): active, deploying, disabled, error, inactive + +**Total: 79 keys** + +## How to Translate All Languages + +### Option 1: Via Web Interface +1. Login as admin: http://localhost:8082/login +2. Go to Settings: http://localhost:8082/settings +3. Add your OpenRouter API key +4. Click "Auto-translate" button for each language + +### Option 2: Via Command Line (Recommended) +```bash +# First, add your OpenRouter API key via Settings page + +# Then run the auto-translation script +docker compose exec app php bin/translate_all.php +``` + +This will automatically translate all 5 languages: +- 🇷🇺 Russian (ru) +- 🇪🇸 Spanish (es) +- 🇩🇪 German (de) +- 🇫🇷 French (fr) +- 🇨🇳 Chinese (zh) + +### Option 3: Translate Single Language +```bash +# Translate only Russian +docker compose exec app php bin/translate.php ru + +# Translate only Spanish +docker compose exec app php bin/translate.php es +``` + +## Current Status + +After migration: +``` ++---------------+-------+ +| language_code | count | ++---------------+-------+ +| en | 79 | ++---------------+-------+ +``` + +After auto-translation (expected): +``` ++---------------+-------+ +| language_code | count | ++---------------+-------+ +| de | 79 | +| en | 79 | +| es | 79 | +| fr | 79 | +| ru | 79 | +| zh | 79 | ++---------------+-------+ +``` + +## API Rate Limits + +OpenRouter free models have rate limits: +- **gemini-2.0-flash-exp:free** - Primary model +- **meta-llama/llama-3.2-3b-instruct:free** - Fallback 1 +- **google/gemini-flash-1.5** - Fallback 2 + +The translation script includes: +- Automatic retries with exponential backoff +- Model fallback on rate limits +- 5-second delay between languages +- Batch translation for efficiency + +## Troubleshooting + +### Error: "OpenRouter API key not found" +Add your API key via Settings page first: +1. Go to http://localhost:8082/settings +2. Enter your OpenRouter API key (format: `sk-or-v1-...`) +3. Click Save + +### Error: "Rate limit exceeded" +Wait a few minutes and try again, or: +- Use the web interface (slower but more controlled) +- Increase delay in `bin/translate_all.php` +- Get a paid OpenRouter API key + +### Check Translation Progress +```bash +docker compose exec db sh -c 'mysql -u root -p"$MYSQL_ROOT_PASSWORD" amnezia_panel -e " +SELECT + l.code, + l.name, + COUNT(t.id) as translated, + (SELECT COUNT(*) FROM translations WHERE language_code = \"en\") as total +FROM languages l +LEFT JOIN translations t ON l.code = t.language_code +GROUP BY l.code +ORDER BY l.code; +"' +``` + +## Manual Export/Import + +### Export translations to JSON +```bash +docker compose exec app php -r " +require 'vendor/autoload.php'; +require 'inc/Config.php'; +require 'inc/DB.php'; +require 'inc/Translator.php'; +Config::load('.env'); +DB::conn(); +echo Translator::exportToJson('ru'); +" > translations_ru.json +``` + +### Import from JSON +```php +Translator::importFromJson('ru', file_get_contents('translations_ru.json')); +``` diff --git a/apache.conf b/apache.conf new file mode 100644 index 0000000..67ccfd2 --- /dev/null +++ b/apache.conf @@ -0,0 +1,13 @@ + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html/public + + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/bin/translate.php b/bin/translate.php new file mode 100755 index 0000000..258366c --- /dev/null +++ b/bin/translate.php @@ -0,0 +1,75 @@ +#!/usr/bin/env php + + * Example: php translate.php ru + */ + +require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../inc/Config.php'; +require_once __DIR__ . '/../inc/DB.php'; +require_once __DIR__ . '/../inc/Translator.php'; + +Config::load(__DIR__ . '/../.env'); + +if ($argc < 2) { + echo "Usage: php translate.php \n"; + echo "Available languages: ru, es, de, fr, zh\n"; + echo "\nExample:\n"; + echo " php translate.php ru # Translate to Russian\n"; + echo " php translate.php all # Translate all languages\n"; + exit(1); +} + +$targetLang = $argv[1]; + +// Initialize (without session) +session_start(); +Translator::init(); + +if ($targetLang === 'all') { + $languages = ['ru', 'es', 'de', 'fr', 'zh']; + + echo "Auto-translating all languages...\n\n"; + + foreach ($languages as $lang) { + echo "Translating to $lang...\n"; + $stats = Translator::translateMissingKeys($lang); + + echo " Total keys: {$stats['total']}\n"; + echo " Translated: {$stats['translated']}\n"; + echo " Failed: {$stats['failed']}\n"; + echo " Progress: " . round(($stats['translated'] / $stats['total']) * 100, 2) . "%\n\n"; + } + + echo "✓ All translations completed!\n"; +} else { + if (!Translator::isSupported($targetLang)) { + echo "Error: Language '$targetLang' is not supported\n"; + echo "Available languages: en, ru, es, de, fr, zh\n"; + exit(1); + } + + if ($targetLang === 'en') { + echo "Error: English is the source language, no translation needed\n"; + exit(1); + } + + echo "Auto-translating to $targetLang...\n"; + + $stats = Translator::translateMissingKeys($targetLang); + + echo "\nTranslation Statistics:\n"; + echo " Total keys: {$stats['total']}\n"; + echo " Translated: {$stats['translated']}\n"; + echo " Failed: {$stats['failed']}\n"; + echo " Progress: " . round(($stats['translated'] / $stats['total']) * 100, 2) . "%\n"; + + if ($stats['failed'] > 0) { + echo "\n⚠ Some translations failed. This might be due to API rate limits.\n"; + echo " Try running the script again later.\n"; + } else { + echo "\n✓ Translation completed successfully!\n"; + } +} diff --git a/bin/translate_all.php b/bin/translate_all.php new file mode 100755 index 0000000..d0b8c53 --- /dev/null +++ b/bin/translate_all.php @@ -0,0 +1,104 @@ +#!/usr/bin/env php +getApiKey('openrouter'); + +if (empty($apiKey)) { + echo "❌ Error: OpenRouter API key not found in database.\n"; + echo "Please add your API key in Settings page first.\n"; + exit(1); +} + +echo "✅ OpenRouter API key found\n\n"; + +// Get all languages except English +$pdo = DB::conn(); +$stmt = $pdo->query("SELECT code, name FROM languages WHERE code != 'en' ORDER BY code"); +$languages = $stmt->fetchAll(); + +echo "Languages to translate: " . count($languages) . "\n"; +foreach ($languages as $lang) { + echo " - {$lang['name']} ({$lang['code']})\n"; +} + +echo "\nStarting translation...\n\n"; + +foreach ($languages as $lang) { + $langCode = $lang['code']; + $langName = $lang['name']; + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + echo "Translating to: {$langName} ({$langCode})\n"; + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + + try { + $stats = Translator::translateMissingKeys($langCode); + + echo "✅ Translation completed!\n"; + echo " Total keys: {$stats['total']}\n"; + echo " Translated: {$stats['translated']}\n"; + echo " Already existed: {$stats['existing']}\n"; + echo " Failed: {$stats['failed']}\n\n"; + + // Sleep to avoid rate limiting + if ($stats['translated'] > 0) { + echo "⏳ Waiting 5 seconds to avoid rate limits...\n\n"; + sleep(5); + } + } catch (Exception $e) { + echo "❌ Error: {$e->getMessage()}\n\n"; + } +} + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; +echo "✅ All translations completed!\n"; +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"; + +// Show final statistics +echo "\nFinal Statistics:\n"; +$stmt = $pdo->query(" + SELECT + l.code, + l.name, + COUNT(DISTINCT t.translation_key) as translated, + (SELECT COUNT(DISTINCT translation_key) FROM translations WHERE language_code = 'en') as total + FROM languages l + LEFT JOIN translations t ON l.code = t.language_code + GROUP BY l.code, l.name + ORDER BY l.code +"); + +$results = $stmt->fetchAll(); +foreach ($results as $row) { + $percent = round(($row['translated'] / $row['total']) * 100); + $bar = str_repeat('█', (int)($percent / 5)); + $empty = str_repeat('░', 20 - (int)($percent / 5)); + echo sprintf( + " %s (%s): [%s%s] %3d%% (%d/%d)\n", + $row['name'], + $row['code'], + $bar, + $empty, + $percent, + $row['translated'], + $row['total'] + ); +} + +echo "\n"; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d34ca3d --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "amnezia/web-panel", + "description": "Amnezia VPN Web Management Panel", + "type": "project", + "license": "MIT", + "authors": [ + { + "name": "Amnezia VPN Community" + } + ], + "require": { + "php": ">=8.0", + "twig/twig": "^3.8", + "endroid/qr-code": "^4.8 || ^5.0", + "firebase/php-jwt": "^6.11", + "ext-pdo": "*", + "ext-json": "*", + "ext-curl": "*", + "ext-gd": "*", + "ext-sodium": "*" + }, + "autoload": { + "psr-4": { + "": "inc/" + } + } +} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php new file mode 100644 index 0000000..22a3d64 --- /dev/null +++ b/controllers/SettingsController.php @@ -0,0 +1,296 @@ +pdo = DB::conn(); + $this->translator = new Translator(); + } + + public function index() { + $stats = $this->getTranslationStats(); + $users = $this->getAllUsers(); + $apiKey = $this->getApiKey('openrouter'); + + $data = [ + 'translation_stats' => $stats, + 'users' => $users, + 'openrouter_key' => $apiKey + ]; + + // Check for session messages + if (isset($_SESSION['settings_success'])) { + $data['success'] = $_SESSION['settings_success']; + unset($_SESSION['settings_success']); + } + if (isset($_SESSION['settings_error'])) { + $data['error'] = $_SESSION['settings_error']; + unset($_SESSION['settings_error']); + } + + View::render('settings.twig', $data); + } + + public function changePassword() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /settings'); + exit; + } + + $user = Auth::user(); + $currentPassword = $_POST['current_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + if (empty($currentPassword) || empty($newPassword) || empty($confirmPassword)) { + $_SESSION['settings_error'] = 'All fields are required'; + header('Location: /settings#profile'); + exit; + } + + if ($newPassword !== $confirmPassword) { + $_SESSION['settings_error'] = 'New passwords do not match'; + header('Location: /settings#profile'); + exit; + } + + if (strlen($newPassword) < 6) { + $_SESSION['settings_error'] = 'Password must be at least 6 characters'; + header('Location: /settings#profile'); + exit; + } + + // Verify current password + if (!password_verify($currentPassword, $user['password_hash'])) { + $_SESSION['settings_error'] = 'Current password is incorrect'; + header('Location: /settings#profile'); + exit; + } + + // Update password + $newHash = password_hash($newPassword, PASSWORD_BCRYPT); + $stmt = $this->pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?"); + $stmt->execute([$newHash, $user['id']]); + + $_SESSION['settings_success'] = 'Password changed successfully'; + header('Location: /settings#profile'); + exit; + } + + public function addUser() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /settings'); + exit; + } + + $user = Auth::user(); + if ($user['role'] !== 'admin') { + http_response_code(403); + echo 'Forbidden'; + return; + } + + $name = trim($_POST['name'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $password = $_POST['password'] ?? ''; + $role = $_POST['role'] ?? 'user'; + + if (empty($name) || empty($email) || empty($password)) { + $_SESSION['settings_error'] = 'All fields are required'; + header('Location: /settings#users'); + exit; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $_SESSION['settings_error'] = 'Invalid email address'; + header('Location: /settings#users'); + exit; + } + + if (strlen($password) < 6) { + $_SESSION['settings_error'] = 'Password must be at least 6 characters'; + header('Location: /settings#users'); + exit; + } + + // Check if email already exists + $stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?"); + $stmt->execute([$email]); + if ($stmt->fetch()) { + $_SESSION['settings_error'] = 'Email already exists'; + header('Location: /settings#users'); + exit; + } + + // Create user + $passwordHash = password_hash($password, PASSWORD_BCRYPT); + $stmt = $this->pdo->prepare("INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)"); + $stmt->execute([$name, $email, $passwordHash, $role]); + + $_SESSION['settings_success'] = 'User added successfully'; + header('Location: /settings#users'); + exit; + } + + public function deleteUser($userId) { + $user = Auth::user(); + if ($user['role'] !== 'admin') { + http_response_code(403); + echo 'Forbidden'; + return; + } + + if ($userId == $user['id']) { + $_SESSION['settings_error'] = 'Cannot delete yourself'; + header('Location: /settings#users'); + exit; + } + + $stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?"); + $stmt->execute([$userId]); + + $_SESSION['settings_success'] = 'User deleted successfully'; + header('Location: /settings#users'); + exit; + } + + private function getAllUsers() { + $stmt = $this->pdo->query("SELECT id, name, email, role, created_at FROM users ORDER BY created_at DESC"); + return $stmt->fetchAll(); + } + + private function getApiKey($service) { + $stmt = $this->pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = ? AND is_active = 1"); + $stmt->execute([$service]); + $result = $stmt->fetch(); + return $result ? $result['api_key'] : null; + } + + public function saveApiKey() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /settings'); + exit; + } + + $service = $_POST['service'] ?? ''; + $apiKey = trim($_POST['api_key'] ?? ''); + + if (empty($service) || empty($apiKey)) { + View::render('settings.twig', [ + 'error' => $this->translator->translate('settings.error_empty_key'), + 'translation_stats' => $this->getTranslationStats() + ]); + return; + } + + // Validate OpenRouter key format + if ($service === 'openrouter' && !preg_match('/^sk-or-v1-[a-zA-Z0-9]{64,}$/', $apiKey)) { + View::render('settings.twig', [ + 'error' => $this->translator->translate('settings.error_invalid_key'), + 'translation_stats' => $this->getTranslationStats() + ]); + return; + } + + // Test the API key + if ($service === 'openrouter') { + $testResult = $this->testOpenRouterKey($apiKey); + if (!$testResult['success']) { + View::render('settings.twig', [ + 'error' => $this->translator->translate('settings.error_key_test') . ': ' . $testResult['error'], + 'translation_stats' => $this->getTranslationStats() + ]); + return; + } + } + + // Save the key + $saved = $this->translator->saveApiKey($service, $apiKey); + + if ($saved) { + View::render('settings.twig', [ + 'success' => $this->translator->translate('settings.key_saved'), + 'translation_stats' => $this->getTranslationStats(), + 'openrouter_key' => '' // Don't show the saved key + ]); + } else { + View::render('settings.twig', [ + 'error' => $this->translator->translate('message.error'), + 'translation_stats' => $this->getTranslationStats() + ]); + } + } + + private function testOpenRouterKey($apiKey) { + // Test with a simple translation request + $url = 'https://openrouter.ai/api/v1/chat/completions'; + $data = [ + 'model' => 'google/gemini-2.0-flash-exp:free', + 'messages' => [ + ['role' => 'user', 'content' => 'Translate "test" to Spanish. Reply only with the translation.'] + ], + 'temperature' => 0.3, + 'max_tokens' => 10 + ]; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + 'HTTP-Referer: https://amnez.ia', + 'X-Title: Amnezia VPN Panel' + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200) { + $result = json_decode($response, true); + if (isset($result['choices'][0]['message']['content'])) { + return ['success' => true]; + } + } + + $error = json_decode($response, true); + return [ + 'success' => false, + 'error' => $error['error']['message'] ?? 'Unknown error (HTTP ' . $httpCode . ')' + ]; + } + + private function getTranslationStats() { + // Get all languages + $stmt = $this->pdo->query("SELECT * FROM languages ORDER BY code"); + $languages = $stmt->fetchAll(); + + // Get total translation keys count + $stmt = $this->pdo->query("SELECT COUNT(DISTINCT translation_key) as count FROM translations WHERE language_code = 'en'"); + $totalKeys = $stmt->fetch(); + $totalCount = $totalKeys['count']; + + $stats = []; + foreach ($languages as $lang) { + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) as count FROM translations WHERE language_code = ? AND translation_value IS NOT NULL AND translation_value != ''" + ); + $stmt->execute([$lang['code']]); + $translated = $stmt->fetch(); + + $stats[] = [ + 'code' => $lang['code'], + 'name' => $lang['name'], + 'native_name' => $lang['native_name'], + 'total_count' => $totalCount, + 'translated_count' => $translated['count'] + ]; + } + + return $stats; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..98f5d35 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + db: + image: mysql:8.0 + container_name: amnezia-panel-db + restart: unless-stopped + env_file: + - .env + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword} + MYSQL_DATABASE: ${DB_DATABASE:-amnezia_panel} + MYSQL_USER: ${DB_USERNAME:-amnezia} + MYSQL_PASSWORD: ${DB_PASSWORD:-amnezia} + ports: + - "3307:3306" + volumes: + - db_data:/var/lib/mysql + - ./migrations:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 10 + + web: + build: + context: . + dockerfile: Dockerfile + container_name: amnezia-panel-web + restart: unless-stopped + depends_on: + db: + condition: service_healthy + env_file: + - .env + environment: + APP_ENV: ${APP_ENV:-local} + DEFAULT_LOCALE: ${DEFAULT_LOCALE:-en} + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: ${DB_DATABASE:-amnezia_panel} + DB_USERNAME: ${DB_USERNAME:-amnezia} + DB_PASSWORD: ${DB_PASSWORD:-amnezia} + volumes: + - ./:/var/www/html + ports: + - "8082:80" + +volumes: + db_data: diff --git a/inc/Auth.php b/inc/Auth.php new file mode 100644 index 0000000..3b59a17 --- /dev/null +++ b/inc/Auth.php @@ -0,0 +1,95 @@ +prepare('SELECT id FROM users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + if ($stmt->fetchColumn()) return false; + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare('INSERT INTO users (email, password_hash, name, role, status) VALUES (?, ?, ?, ?, ?)'); + return $stmt->execute([$email, $hash, $name ?: $email, 'user', 'active']); + } + + public static function login(string $email, string $password): bool { + $pdo = DB::conn(); + $email = strtolower(trim($email)); + $stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $user = $stmt->fetch(); + if (!$user) return false; + if (!password_verify($password, $user['password_hash'])) return false; + $_SESSION['user_id'] = (int)$user['id']; + $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')->execute([$user['id']]); + return true; + } + + public static function logout(): void { unset($_SESSION['user_id']); } + public static function check(): bool { return isset($_SESSION['user_id']); } + + public static function getUserByEmail(string $email): ?array { + $pdo = DB::conn(); + $email = strtolower(trim($email)); + $stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $user = $stmt->fetch(); + return $user ?: null; + } + + public static function user(): ?array { + if (!self::check()) return null; + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM users WHERE id = ? LIMIT 1'); + $stmt->execute([$_SESSION['user_id']]); + $u = $stmt->fetch(); + return $u ?: null; + } + + public static function isAdmin(): bool { + $u = self::user(); + return $u && ($u['role'] === 'admin'); + } + + public static function seedAdmin(string $email, string $password): void { + $pdo = DB::conn(); + $email = strtolower(trim($email)); + $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + if ($stmt->fetchColumn()) return; + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare('INSERT INTO users (email, password_hash, name, role, status) VALUES (?, ?, ?, ?, ?)'); + $stmt->execute([$email, $hash, 'Administrator', 'admin', 'active']); + } + + public static function listUsers(): array { + $pdo = DB::conn(); + $stmt = $pdo->query('SELECT id, email, name, role, status, created_at, last_login_at FROM users ORDER BY id DESC'); + return $stmt->fetchAll(); + } + + public static function setRole(int $userId, string $role): bool { + if (!in_array($role, ['admin','user'], true)) return false; + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?'); + return $stmt->execute([$role, $userId]); + } + + public static function saveSetting(?int $userId, string $namespace, string $key, string $valueJson): bool { + $pdo = DB::conn(); + $stmt = $pdo->prepare('INSERT INTO settings (user_id, namespace, `key`, `value`) VALUES (?, ?, ?, CAST(? AS JSON)) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_at = NOW()'); + return $stmt->execute([$userId, $namespace, $key, $valueJson]); + } + + public static function getSetting(?int $userId, string $namespace, string $key): array { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT `value` FROM settings WHERE user_id <=> ? AND namespace = ? AND `key` = ? LIMIT 1'); + $stmt->execute([$userId, $namespace, $key]); + $val = $stmt->fetchColumn(); + if (!$val) return []; + $decoded = json_decode($val, true); + return is_array($decoded) ? $decoded : []; + } +} \ No newline at end of file diff --git a/inc/Config.php b/inc/Config.php new file mode 100644 index 0000000..303644d --- /dev/null +++ b/inc/Config.php @@ -0,0 +1,28 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + self::$pdo = new PDO($dsn, $user, $pass, $options); + return self::$pdo; + } +} \ No newline at end of file diff --git a/inc/JWT.php b/inc/JWT.php new file mode 100644 index 0000000..c2c3f52 --- /dev/null +++ b/inc/JWT.php @@ -0,0 +1,268 @@ += 32) { + self::$secretKey = $envKey; + return self::$secretKey; + } + + // Try to get from database settings + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT value FROM settings WHERE key = ?'); + $stmt->execute(['jwt_secret']); + $result = $stmt->fetch(); + + if ($result && !empty($result['value'])) { + self::$secretKey = $result['value']; + return self::$secretKey; + } + + // Generate new secret key and save it + $newKey = bin2hex(random_bytes(32)); + + $stmt = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?'); + $stmt->execute(['jwt_secret', $newKey, $newKey]); + + self::$secretKey = $newKey; + return self::$secretKey; + } + + /** + * Generate JWT token for user + * + * @param int $userId User ID + * @param int $expiresIn Token lifetime in seconds (default: 30 days) + * @return string JWT token + */ + public static function generate(int $userId, int $expiresIn = 2592000): string { + $issuedAt = time(); + $expire = $issuedAt + $expiresIn; + + $payload = [ + 'iss' => 'amnezia-panel', // Issuer + 'aud' => 'amnezia-api', // Audience + 'iat' => $issuedAt, // Issued at + 'exp' => $expire, // Expiration + 'sub' => $userId, // Subject (user ID) + 'jti' => bin2hex(random_bytes(16)) // JWT ID (unique token identifier) + ]; + + return FirebaseJWT::encode($payload, self::getSecretKey(), 'HS256'); + } + + /** + * Validate and decode JWT token + * + * @param string $token JWT token + * @return object|null Decoded token payload or null if invalid + */ + public static function decode(string $token): ?object { + try { + $decoded = FirebaseJWT::decode($token, new Key(self::getSecretKey(), 'HS256')); + + // Verify issuer and audience + if ($decoded->iss !== 'amnezia-panel' || $decoded->aud !== 'amnezia-api') { + return null; + } + + return $decoded; + } catch (Exception $e) { + error_log('JWT decode error: ' . $e->getMessage()); + return null; + } + } + + /** + * Get user ID from JWT token + * + * @param string $token JWT token + * @return int|null User ID or null if invalid + */ + public static function getUserId(string $token): ?int { + $decoded = self::decode($token); + + if ($decoded === null) { + return null; + } + + return (int)$decoded->sub; + } + + /** + * Verify JWT token and get user data + * + * @param string $token JWT token + * @return array|null User data or null if invalid + */ + public static function verify(string $token): ?array { + $userId = self::getUserId($token); + + if ($userId === null) { + return null; + } + + // Get user from database + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT id, name, email, role FROM users WHERE id = ?'); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + return $user ?: null; + } + + /** + * Extract token from Authorization header + * + * @return string|null Token or null if not found + */ + public static function getTokenFromHeader(): ?string { + // Try getallheaders() first (Apache/FPM) + if (function_exists('getallheaders')) { + $headers = getallheaders(); + } else { + // Fallback for other environments (nginx, CLI) + $headers = []; + foreach ($_SERVER as $key => $value) { + if (strpos($key, 'HTTP_') === 0) { + $header = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5))))); + $headers[$header] = $value; + } + } + } + + // Check Authorization header + if (isset($headers['Authorization'])) { + $auth = $headers['Authorization']; + + // Bearer token format: "Bearer {token}" + if (preg_match('/Bearer\s+(.+)/', $auth, $matches)) { + return $matches[1]; + } + } + + // Check X-API-Token header (alternative) + if (isset($headers['X-Api-Token'])) { + return $headers['X-Api-Token']; + } + + // Also check direct $_SERVER access for Authorization + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $auth = $_SERVER['HTTP_AUTHORIZATION']; + if (preg_match('/Bearer\s+(.+)/', $auth, $matches)) { + return $matches[1]; + } + } + + return null; + } + + /** + * Middleware: Require JWT authentication for API endpoints + * + * @return array|null User data if authenticated, sends 401 response and returns null if not + */ + public static function requireAuth(): ?array { + $token = self::getTokenFromHeader(); + + if ($token === null) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Missing authentication token']); + return null; + } + + $user = self::verify($token); + + if ($user === null) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Invalid or expired token']); + return null; + } + + return $user; + } + + /** + * Create API token for user (saves to database) + * + * @param int $userId User ID + * @param string|null $name Token name/description + * @param int $expiresIn Token lifetime in seconds (default: 30 days) + * @return array Token data (id, token, expires_at) + */ + public static function createApiToken(int $userId, ?string $name = null, int $expiresIn = 2592000): array { + $token = self::generate($userId, $expiresIn); + $expiresAt = date('Y-m-d H:i:s', time() + $expiresIn); + + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + INSERT INTO api_tokens (user_id, token, name, expires_at) + VALUES (?, ?, ?, ?) + '); + + $stmt->execute([ + $userId, + $token, + $name ?? 'API Token', + $expiresAt + ]); + + return [ + 'id' => (int)$pdo->lastInsertId(), + 'token' => $token, + 'name' => $name ?? 'API Token', + 'expires_at' => $expiresAt + ]; + } + + /** + * Revoke API token + * + * @param int $tokenId Token ID + * @param int $userId User ID (for ownership verification) + * @return bool Success + */ + public static function revokeApiToken(int $tokenId, int $userId): bool { + $pdo = DB::conn(); + $stmt = $pdo->prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?'); + return $stmt->execute([$tokenId, $userId]); + } + + /** + * Get all API tokens for user + * + * @param int $userId User ID + * @return array List of tokens + */ + public static function getUserTokens(int $userId): array { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + SELECT id, name, LEFT(token, 20) as token_preview, created_at, expires_at + FROM api_tokens + WHERE user_id = ? AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC + '); + $stmt->execute([$userId]); + return $stmt->fetchAll(); + } +} diff --git a/inc/QrUtil.php b/inc/QrUtil.php new file mode 100644 index 0000000..346cafe --- /dev/null +++ b/inc/QrUtil.php @@ -0,0 +1,235 @@ +setSize($size) + ->setMargin($margin) + ->setErrorCorrectionLevel(ErrorCorrectionLevel::Medium) + ->setEncoding(new Encoding('UTF-8')); + + if (class_exists(PngWriter::class) && extension_loaded('gd')) { + // Avoid labels in PNG to sidestep GD freetype dependency + $writer = new PngWriter(); + $result = $writer->write($qrCode); + return 'data:image/png;base64,' . base64_encode($result->getString()); + } + if (class_exists(SvgWriter::class)) { + $writer = new SvgWriter(); + $result = $writer->write($qrCode, null, Label::create($label)->setAlignment(LabelAlignment::Center)); + return 'data:image/svg+xml;base64,' . base64_encode($result->getString()); + } + } + // Fallback to phpqrcode.php if available + $libPath = __DIR__ . '/phpqrcode.php'; + if (file_exists($libPath)) { + require_once $libPath; + ob_start(); + // Avoid direct constant references to satisfy linter + $args = [$text]; + if (function_exists('constant') && defined('QR_ECLEVEL_M')) { + $args = [$text, null, constant('QR_ECLEVEL_M'), $size / 40, $margin]; + } + call_user_func_array(['QRcode', 'png'], $args); + $png = ob_get_clean(); + return 'data:image/png;base64,' . base64_encode($png); + } + throw new RuntimeException('QR library not available'); + } + + private static function urlsafe_b64_encode(string $bytes): string { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } + + public static function encodeOldPayloadFromJson(string $jsonText): string { + $json = self::normalizeJson($jsonText); + // Old format uses zlib (gzcompress) with header [version, compressed_len, uncompressed_len] + $compressed = gzcompress($json, 9); + if ($compressed === false) { + throw new RuntimeException('gzcompress failed'); + } + $uncompressedLen = strlen($json); + $compressedLen = strlen($compressed) + 4; + $version = 0x07C00100; // align with working payload header (big-endian) + $header = pack('N3', $version, $compressedLen, $uncompressedLen); + return self::urlsafe_b64_encode($header . $compressed); + } + + public static function encodeOldPayloadFromConf(string $confText): string { + $payload = self::buildOldEnvelopeFromConf($confText); + return self::encodeOldPayloadFromJson(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + + private static function resolveServerDescription(?string $endpointHost): string { + $desc = (string)($endpointHost ?? ''); + try { + $cfgPath = __DIR__ . '/config.php'; + $dbPath = __DIR__ . '/Database.php'; + if (file_exists($cfgPath) && file_exists($dbPath)) { + $config = require $cfgPath; + require_once $dbPath; + $pdo = (new Database($config['db']))->pdo(); + $stmt = $pdo->prepare('SELECT name FROM servers WHERE host=? LIMIT 1'); + $stmt->execute([$endpointHost]); + $row = $stmt->fetch(); + if ($row && !empty($row['name'])) { + $desc = $row['name']; + } + } + } catch (\Throwable $e) { + // fallback to host + } + return $desc; + } + + private static function buildOldEnvelopeFromConf(string $conf): array { + $endpointHost = null; $endpointPort = null; $mtu = null; $dns = []; $keepAlive = null; + $privKey = null; $pubKeyServer = null; $psk = null; $address = null; $allowedIps = []; + foreach (explode("\n", $conf) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { continue; } + if (stripos($line, 'Endpoint') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + if (preg_match('/^\[?([^\]]+)\]?:([0-9]{2,5})$/', $v, $m)) { + $endpointHost = $m[1]; + $endpointPort = (int)$m[2]; + } + } elseif (stripos($line, 'MTU') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $mtu = (int)$v; + } elseif (stripos($line, 'DNS') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $dns = array_map('trim', preg_split('/[,\s]+/', $v)); + } elseif (stripos($line, 'PrivateKey') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $privKey = $v; + } elseif (stripos($line, 'PublicKey') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $pubKeyServer = $v; + } elseif (stripos($line, 'PresharedKey') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $psk = $v; + } elseif (stripos($line, 'Address') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $address = $v; + } elseif (stripos($line, 'AllowedIPs') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $allowedIps = array_map('trim', preg_split('/[,\s]+/', $v)); + } elseif (stripos($line, 'PersistentKeepalive') === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $keepAlive = (int)$v; + } + } + + if (!$endpointPort) { $endpointPort = 51820; } + if (!$mtu) { $mtu = 1280; } + if (!$keepAlive) { $keepAlive = 25; } + $dns1 = $dns[0] ?? '1.1.1.1'; + $dns2 = $dns[1] ?? '1.0.0.1'; + + // Derive client public key if sodium available + $clientPubKey = ''; + if ($privKey && function_exists('sodium_crypto_scalarmult_base')) { + $bin = base64_decode($privKey, true); + if ($bin !== false && strlen($bin) === 32) { + $pub = sodium_crypto_scalarmult_base($bin); + $clientPubKey = base64_encode($pub); + } + } + + // Collect obfuscation params from conf if present + $params = [ + 'H1' => null, 'H2' => null, 'H3' => null, 'H4' => null, + 'Jc' => null, 'Jmin' => null, 'Jmax' => null, + 'S1' => null, 'S2' => null, + ]; + foreach (explode("\n", $conf) as $line) { + $line = trim($line); + foreach (array_keys($params) as $k) { + if (stripos($line, $k) === 0 && strpos($line, '=') !== false) { + [, $v] = array_map('trim', explode('=', $line, 2)); + $params[$k] = $v; + } + } + } + + // Build last_config JSON object (stringified, pretty-printed) + $lastConfigObj = [ + 'H1' => (string)($params['H1'] ?? ''), + 'H2' => (string)($params['H2'] ?? ''), + 'H3' => (string)($params['H3'] ?? ''), + 'H4' => (string)($params['H4'] ?? ''), + 'Jc' => (string)($params['Jc'] ?? ''), + 'Jmax' => (string)($params['Jmax'] ?? ''), + 'Jmin' => (string)($params['Jmin'] ?? ''), + 'S1' => (string)($params['S1'] ?? ''), + 'S2' => (string)($params['S2'] ?? ''), + 'allowed_ips' => $allowedIps ?: ['0.0.0.0/0', '::/0'], + 'clientId' => $clientPubKey ?: '', + 'client_ip' => preg_replace('/\/(\d{1,2})$/', '', (string)($address ?? '')), + 'client_priv_key' => (string)($privKey ?? ''), + 'client_pub_key' => $clientPubKey ?: '', + 'config' => $conf, + 'hostName' => (string)($endpointHost ?? ''), + 'mtu' => (string)$mtu, + 'persistent_keep_alive' => (string)$keepAlive, + 'port' => $endpointPort, + 'psk_key' => (string)($psk ?? ''), + 'server_pub_key' => (string)($pubKeyServer ?? ''), + ]; + + $serverDesc = self::resolveServerDescription($endpointHost); + + // Envelope with keys ordered like variant 1: containers first + $envelope = [ + 'containers' => [ + [ + // awg first, then container (as in the working QR) + 'awg' => [ + 'H1' => (string)($params['H1'] ?? ''), + 'H2' => (string)($params['H2'] ?? ''), + 'H3' => (string)($params['H3'] ?? ''), + 'H4' => (string)($params['H4'] ?? ''), + 'Jc' => (string)($params['Jc'] ?? ''), + 'Jmax' => (string)($params['Jmax'] ?? ''), + 'Jmin' => (string)($params['Jmin'] ?? ''), + 'S1' => (string)($params['S1'] ?? ''), + 'S2' => (string)($params['S2'] ?? ''), + 'last_config' => json_encode($lastConfigObj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), + 'port' => (string)$endpointPort, + 'transport_proto' => 'udp', + ], + 'container' => 'amnezia-awg', + ], + ], + 'defaultContainer' => 'amnezia-awg', + 'description' => $serverDesc, + 'dns1' => $dns1, + 'dns2' => $dns2, + 'hostName' => $endpointHost, + ]; + return $envelope; + } + + private static function normalizeJson(string $text): string { + $decoded = json_decode($text, true); + if (!is_array($decoded)) throw new InvalidArgumentException('Invalid JSON'); + return json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } +} \ No newline at end of file diff --git a/inc/Router.php b/inc/Router.php new file mode 100644 index 0000000..122e855 --- /dev/null +++ b/inc/Router.php @@ -0,0 +1,37 @@ + strtoupper($method), + 'pattern' => self::normalizePattern($pattern), + 'handler' => $handler, + ]; + } + public static function get(string $pattern, callable $handler): void { self::add('GET', $pattern, $handler); } + public static function post(string $pattern, callable $handler): void { self::add('POST', $pattern, $handler); } + public static function delete(string $pattern, callable $handler): void { self::add('DELETE', $pattern, $handler); } + + private static function normalizePattern(string $pattern): string { + $pattern = '/' . trim($pattern, '/'); + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $pattern); + return '#^' . $pattern . '$#'; + } + + public static function dispatch(string $method, string $uri): void { + $path = parse_url($uri, PHP_URL_PATH) ?: '/'; + $path = '/' . trim($path, '/'); + foreach (self::$routes as $route) { + if ($route['method'] !== strtoupper($method)) continue; + if (preg_match($route['pattern'], $path, $matches)) { + $params = []; + foreach ($matches as $k => $v) { if (!is_int($k)) $params[$k] = $v; } + call_user_func($route['handler'], $params); + return; + } + } + http_response_code(404); + echo '404 Not Found'; + } +} \ No newline at end of file diff --git a/inc/Translator.php b/inc/Translator.php new file mode 100644 index 0000000..b150b87 --- /dev/null +++ b/inc/Translator.php @@ -0,0 +1,609 @@ +query('SELECT code, name, native_name FROM languages WHERE is_active = 1'); + self::$supportedLanguages = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Detect user's preferred language + */ + private static function detectLanguage(): void { + // 1. Check session + if (isset($_SESSION['language'])) { + self::$currentLanguage = $_SESSION['language']; + return; + } + + // 2. Check cookie + if (isset($_COOKIE['language'])) { + self::$currentLanguage = $_COOKIE['language']; + $_SESSION['language'] = self::$currentLanguage; + return; + } + + // 3. Check browser language + if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + $browserLang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2); + if (self::isSupported($browserLang)) { + self::$currentLanguage = $browserLang; + $_SESSION['language'] = self::$currentLanguage; + return; + } + } + + // 4. Default to English + self::$currentLanguage = 'en'; + $_SESSION['language'] = 'en'; + } + + /** + * Check if language is supported + */ + public static function isSupported(string $code): bool { + foreach (self::$supportedLanguages as $lang) { + if ($lang['code'] === $code) { + return true; + } + } + return false; + } + + /** + * Load translations for specific language + */ + private static function loadTranslations(string $languageCode): void { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT translation_key, translation_value FROM translations WHERE language_code = ?'); + $stmt->execute([$languageCode]); + + $translations = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + self::$translations = $translations ?: []; + } + + /** + * Translate a key + * + * @param string $key Translation key + * @param array $params Parameters for sprintf + * @return string Translated text + */ + public static function translate(string $key, array $params = []): string { + $translation = self::$translations[$key] ?? $key; + + if (!empty($params)) { + return sprintf($translation, ...$params); + } + + return $translation; + } + + /** + * Short alias for translate() + */ + public static function t(string $key, array $params = []): string { + return self::translate($key, $params); + } + + /** + * Get current language code + */ + public static function getCurrentLanguage(): string { + return self::$currentLanguage ?? 'en'; + } + + /** + * Set current language + */ + public static function setLanguage(string $code): bool { + if (!self::isSupported($code)) { + return false; + } + + self::$currentLanguage = $code; + $_SESSION['language'] = $code; + setcookie('language', $code, time() + 31536000, '/'); // 1 year + + // Reload translations + self::loadTranslations($code); + + return true; + } + + /** + * Get all supported languages + */ + public static function getSupportedLanguages(): array { + return self::$supportedLanguages; + } + + /** + * Auto-translate missing keys using AI (OpenRouter API) + * + * @param string $targetLang Target language code + * @param string $key Translation key + * @param string $sourceText Source text (English) + * @return bool Success status + */ + public static function autoTranslate(string $targetLang, string $key, string $sourceText): bool { + if ($targetLang === 'en') { + return false; // English is source language + } + + try { + // Language mapping + $langNames = [ + 'ru' => 'Russian', + 'es' => 'Spanish', + 'de' => 'German', + 'fr' => 'French', + 'zh' => 'Chinese' + ]; + + $targetLanguage = $langNames[$targetLang] ?? 'English'; + + // Use OpenRouter API with multiple free model candidates + $translatedText = self::translateWithAI($sourceText, $targetLanguage); + + if (!$translatedText || $translatedText === $sourceText) { + error_log("Translation failed for '{$sourceText}' to {$targetLang}"); + return false; + } + + // Save to database + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + INSERT INTO translations (language_code, translation_key, translation_value) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE translation_value = VALUES(translation_value) + '); + + return $stmt->execute([$targetLang, $key, $translatedText]); + + } catch (Exception $e) { + error_log("Auto-translation error: " . $e->getMessage()); + return false; + } + } + + /** + * Translate text using AI with model fallback + */ + private static function translateWithAI(string $text, string $targetLanguage): ?string { + // Try multiple free models for reliability + $models = [ + 'google/gemini-2.0-flash-exp:free', + 'meta-llama/llama-3.2-3b-instruct:free', + 'qwen/qwen-2-7b-instruct:free' + ]; + + foreach ($models as $model) { + try { + $result = self::callOpenRouter($model, $text, $targetLanguage); + if ($result && $result !== $text) { + return $result; + } + } catch (Exception $e) { + error_log("Model {$model} failed: " . $e->getMessage()); + continue; + } + } + + return null; + } + + /** + * Get OpenRouter API key from database + */ + private static function getOpenRouterKey(): ?string { + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = 'openrouter' AND is_active = 1 LIMIT 1"); + $stmt->execute(); + return $stmt->fetchColumn() ?: null; + } catch (Exception $e) { + error_log('Failed to get OpenRouter API key: ' . $e->getMessage()); + return null; + } + } + + /** + * Call OpenRouter API + */ + private static function callOpenRouter(string $model, string $text, string $targetLanguage): ?string { + $apiKey = self::getOpenRouterKey(); + + if (!$apiKey) { + error_log('OpenRouter API key not configured'); + return null; + } + + $messages = [ + [ + 'role' => 'system', + 'content' => "You are a professional translator. Translate the given English text to {$targetLanguage}. Return ONLY the translation, no explanations or additional text. Keep the same tone and style. If there are parameters in curly braces like {param}, keep them unchanged." + ], + [ + 'role' => 'user', + 'content' => "Translate to {$targetLanguage}: {$text}" + ] + ]; + + $data = [ + 'model' => $model, + 'messages' => $messages, + 'max_tokens' => 200, + 'temperature' => 0.1 + ]; + + $ch = curl_init('https://openrouter.ai/api/v1/chat/completions'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + 'HTTP-Referer: https://amnez.ia', + 'X-Title: Amnezia VPN Panel' + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("OpenRouter API error: HTTP {$httpCode} - Model: {$model}"); + return null; + } + + $result = json_decode($response, true); + + if (!isset($result['choices'][0]['message']['content'])) { + error_log("OpenRouter API error: No content in response - Model: {$model}"); + return null; + } + + return trim($result['choices'][0]['message']['content']); + } + + /** + * Translate all missing keys for a language + * + * @param string $targetLang Target language code + * @return array Statistics (total, translated, failed) + */ + public static function translateMissingKeys(string $targetLang): array { + if ($targetLang === 'en') { + return ['total' => 0, 'translated' => 0, 'failed' => 0]; + } + + $pdo = DB::conn(); + + // Get all English keys + $stmt = $pdo->query("SELECT translation_key, translation_value FROM translations WHERE language_code = 'en'"); + $englishKeys = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + + // Get existing translations for target language + $stmt = $pdo->prepare("SELECT translation_key FROM translations WHERE language_code = ?"); + $stmt->execute([$targetLang]); + $existingKeys = $stmt->fetchAll(PDO::FETCH_COLUMN); + + $stats = [ + 'total' => count($englishKeys), + 'translated' => count($existingKeys), + 'failed' => 0 + ]; + + // Find missing keys + $missingKeys = []; + foreach ($englishKeys as $key => $value) { + if (!in_array($key, $existingKeys)) { + $missingKeys[$key] = $value; + } + } + + if (empty($missingKeys)) { + return $stats; + } + + // Try batch translation first + $batchResult = self::translateBatch($missingKeys, $targetLang); + + if ($batchResult) { + foreach ($batchResult as $key => $translatedText) { + if (isset($missingKeys[$key]) && $translatedText) { + self::setTranslation($targetLang, $key, $translatedText); + $stats['translated']++; + } + } + return $stats; + } + + // Fallback to individual translation + foreach ($missingKeys as $key => $value) { + if (self::autoTranslate($targetLang, $key, $value)) { + $stats['translated']++; + usleep(500000); // 500ms delay between requests + } else { + $stats['failed']++; + } + } + + return $stats; + } + + /** + * Batch translate multiple texts at once (more efficient) + */ + private static function translateBatch(array $texts, string $targetLang): ?array { + if (empty($texts) || !is_array($texts)) { + return null; + } + + try { + $langNames = [ + 'ru' => 'Russian', + 'es' => 'Spanish', + 'de' => 'German', + 'fr' => 'French', + 'zh' => 'Chinese' + ]; + + $targetLanguage = $langNames[$targetLang] ?? 'English'; + + // Prepare texts for JSON + $textsForJson = []; + foreach ($texts as $key => $text) { + $textsForJson[] = [ + 'key' => $key, + 'text' => $text + ]; + } + + $jsonTexts = json_encode($textsForJson, JSON_UNESCAPED_UNICODE); + + $models = [ + 'google/gemini-2.0-flash-exp:free', + 'meta-llama/llama-3.2-3b-instruct:free' + ]; + + foreach ($models as $model) { + try { + $result = self::callOpenRouterBatch($model, $jsonTexts, $targetLanguage); + + if ($result && is_array($result)) { + // Validate results + $translations = []; + foreach ($result as $item) { + if (isset($item['key']) && isset($item['text']) && isset($texts[$item['key']])) { + $translations[$item['key']] = $item['text']; + } + } + + if (count($translations) > 0) { + error_log("Batch translation successful: " . count($translations) . " texts to {$targetLang}"); + return $translations; + } + } + } catch (Exception $e) { + error_log("Batch translation with {$model} failed: " . $e->getMessage()); + continue; + } + } + + return null; + } catch (Exception $e) { + error_log('Batch translation error: ' . $e->getMessage()); + return null; + } + } + + /** + * Call OpenRouter API for batch translation + */ + private static function callOpenRouterBatch(string $model, string $jsonTexts, string $targetLanguage): ?array { + $apiKey = self::getOpenRouterKey(); + + if (!$apiKey) { + error_log('OpenRouter API key not configured'); + return null; + } + + $messages = [ + [ + 'role' => 'system', + 'content' => "You are a professional translator. Translate the given English texts to {$targetLanguage}. Return ONLY a JSON array with objects containing 'key' and 'text' fields. Each 'text' should contain only the translated text. Keep the same tone and style. If there are parameters in curly braces like {param}, keep them unchanged. Do not add any explanations or additional text outside the JSON." + ], + [ + 'role' => 'user', + 'content' => "Translate these English texts to {$targetLanguage}:\n{$jsonTexts}" + ] + ]; + + $data = [ + 'model' => $model, + 'messages' => $messages, + 'max_tokens' => 4000, + 'temperature' => 0.1 + ]; + + $ch = curl_init('https://openrouter.ai/api/v1/chat/completions'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + 'HTTP-Referer: https://amnez.ia', + 'X-Title: Amnezia VPN Panel' + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 60); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("OpenRouter batch API error: HTTP {$httpCode}"); + return null; + } + + $result = json_decode($response, true); + + if (!isset($result['choices'][0]['message']['content'])) { + return null; + } + + $responseText = trim($result['choices'][0]['message']['content']); + + // Remove markdown code blocks if present + if (strpos($responseText, '```json') !== false) { + $responseText = preg_replace('/```json\s*/', '', $responseText); + $responseText = preg_replace('/\s*```/', '', $responseText); + $responseText = trim($responseText); + } + + $translatedJson = json_decode($responseText, true); + + if (!is_array($translatedJson)) { + error_log("Batch translation: Invalid JSON response"); + return null; + } + + return $translatedJson; + } + + /** + * Get translation statistics + */ + public static function getStatistics(): array { + $pdo = DB::conn(); + + $stmt = $pdo->query(" + SELECT + l.code, + l.name, + l.native_name, + COUNT(t.id) as translated_count, + (SELECT COUNT(*) FROM translations WHERE language_code = 'en') as total_count + FROM languages l + LEFT JOIN translations t ON l.code = t.language_code + WHERE l.is_active = 1 + GROUP BY l.code, l.name, l.native_name + "); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Add or update translation + */ + public static function setTranslation(string $languageCode, string $key, string $value): bool { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + INSERT INTO translations (language_code, translation_key, translation_value) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE translation_value = VALUES(translation_value) + '); + + return $stmt->execute([$languageCode, $key, $value]); + } + + /** + * Export translations to JSON file + */ + public static function exportToJson(string $languageCode): string { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT translation_key, translation_value FROM translations WHERE language_code = ?'); + $stmt->execute([$languageCode]); + + $translations = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + + return json_encode($translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + + /** + * Import translations from JSON file + */ + public static function importFromJson(string $languageCode, string $json): bool { + $translations = json_decode($json, true); + + if (!is_array($translations)) { + return false; + } + + $pdo = DB::conn(); + $pdo->beginTransaction(); + + try { + foreach ($translations as $key => $value) { + self::setTranslation($languageCode, $key, $value); + } + + $pdo->commit(); + return true; + } catch (Exception $e) { + $pdo->rollBack(); + return false; + } + } + + /** + * Save API key for translation service + */ + public static function saveApiKey(string $serviceName, string $apiKey): bool { + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + INSERT INTO api_keys (service_name, api_key, is_active) + VALUES (?, ?, 1) + ON DUPLICATE KEY UPDATE api_key = VALUES(api_key), updated_at = NOW() + '); + return $stmt->execute([$serviceName, $apiKey]); + } catch (Exception $e) { + error_log('Failed to save API key: ' . $e->getMessage()); + return false; + } + } + + /** + * Get API key for service + */ + public static function getApiKey(string $serviceName): ?string { + try { + $pdo = DB::conn(); + $stmt = $pdo->prepare("SELECT api_key FROM api_keys WHERE service_name = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([$serviceName]); + return $stmt->fetchColumn() ?: null; + } catch (Exception $e) { + return null; + } + } +} diff --git a/inc/View.php b/inc/View.php new file mode 100644 index 0000000..dcb8341 --- /dev/null +++ b/inc/View.php @@ -0,0 +1,47 @@ + false, + 'autoescape' => 'html', + ]); + + // Add translation function + $tFunc = new TwigFunction('t', function (string $key, array $params = []) { + return Translator::t($key, $params); + }); + self::$twig->addFunction($tFunc); + + // Add flag emoji function + $flagFunc = new TwigFunction('getFlag', function (string $langCode) { + $flags = [ + 'en' => '🇬🇧', + 'ru' => '🇷🇺', + 'es' => '🇪🇸', + 'de' => '🇩🇪', + 'fr' => '🇫🇷', + 'zh' => '🇨🇳', + ]; + return $flags[$langCode] ?? '🌐'; + }); + self::$twig->addFunction($flagFunc); + + // Add globals + foreach ($globals as $k => $v) self::$twig->addGlobal($k, $v); + } + + public static function render(string $template, array $vars = []): void { + if (!self::$twig) throw new RuntimeException('Twig is not initialized'); + echo self::$twig->render($template, $vars); + } +} \ No newline at end of file diff --git a/inc/VpnClient.php b/inc/VpnClient.php new file mode 100644 index 0000000..dc3b8a6 --- /dev/null +++ b/inc/VpnClient.php @@ -0,0 +1,688 @@ +clientId = $clientId; + if ($clientId) { + $this->load(); + } + } + + /** + * Load client data from database + */ + private function load(): void { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM vpn_clients WHERE id = ?'); + $stmt->execute([$this->clientId]); + $this->data = $stmt->fetch(); + if (!$this->data) { + throw new Exception('Client not found'); + } + } + + /** + * Create new VPN client + */ + public static function create(int $serverId, int $userId, string $name): int { + $pdo = DB::conn(); + + // Get server data + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + if (!$serverData || $serverData['status'] !== 'active') { + throw new Exception('Server is not active'); + } + + // Generate client keys + $containerName = $serverData['container_name']; + $keys = self::generateClientKeys($serverData, $name); + + // Get next available IP + $clientIP = self::getNextClientIP($serverData); + + // Get AWG parameters from server + $awgParams = json_decode($serverData['awg_params'], true); + + // Build client configuration + $config = self::buildClientConfig( + $keys['private'], + $clientIP, + $serverData['server_public_key'], + $serverData['preshared_key'], + $serverData['host'], + $serverData['vpn_port'], + $awgParams + ); + + // Add client to server + self::addClientToServer($serverData, $keys['public'], $clientIP); + + // Generate QR code + $qrCode = self::generateQRCode($config); + + // Insert into database + $stmt = $pdo->prepare(' + INSERT INTO vpn_clients + (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + + $stmt->execute([ + $serverId, + $userId, + $name, + $clientIP, + $keys['public'], + $keys['private'], + $serverData['preshared_key'], + $config, + $qrCode, + 'active' + ]); + + return (int)$pdo->lastInsertId(); + } + + /** + * Generate client keys on remote server + */ + private static function generateClientKeys(array $serverData, string $clientName): array { + $containerName = $serverData['container_name']; + + $cmd = sprintf( + "docker exec -i %s sh -c \"umask 077; wg genkey | tee /tmp/%s_priv.key | wg pubkey > /tmp/%s_pub.key; cat /tmp/%s_priv.key; echo '---'; cat /tmp/%s_pub.key; rm -f /tmp/%s_priv.key /tmp/%s_pub.key\"", + $containerName, + $clientName, $clientName, $clientName, $clientName, $clientName, $clientName + ); + + $escaped = escapeshellarg($cmd); + $sshCmd = sprintf( + "sshpass -p '%s' ssh -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", + $serverData['password'], + $serverData['username'], + $serverData['host'], + $escaped + ); + + $out = shell_exec($sshCmd); + $parts = explode("---", trim($out)); + + if (count($parts) < 2) { + throw new Exception("Failed to generate client keys"); + } + + return [ + 'private' => trim($parts[0]), + 'public' => trim($parts[1]) + ]; + } + + /** + * Get next available client IP + */ + private static function getNextClientIP(array $serverData): string { + $pdo = DB::conn(); + + // Get used IPs from database + $stmt = $pdo->prepare('SELECT client_ip FROM vpn_clients WHERE server_id = ? AND status = ?'); + $stmt->execute([$serverData['id'], 'active']); + $usedIPs = $stmt->fetchAll(PDO::FETCH_COLUMN); + + // Parse subnet + $parts = explode('/', $serverData['vpn_subnet']); + $networkLong = ip2long($parts[0]); + + // Reserve network address + $used = ['10.8.1.0' => true]; + foreach ($usedIPs as $ip) { + $used[$ip] = true; + } + + // Find next free IP starting from .1 + for ($i = 1; $i <= 253; $i++) { + $candidate = long2ip($networkLong + $i); + if (!isset($used[$candidate])) { + return $candidate; + } + } + + throw new Exception('No free IP addresses in subnet'); + } + + /** + * Build client configuration file + */ + private static function buildClientConfig( + string $privateKey, + string $clientIP, + string $serverPublicKey, + string $presharedKey, + string $serverHost, + int $serverPort, + array $awgParams + ): string { + $config = "[Interface]\n"; + $config .= "PrivateKey = {$privateKey}\n"; + $config .= "Address = {$clientIP}/32\n"; + $config .= "DNS = 1.1.1.1, 1.0.0.1\n"; + + // Add AWG parameters + foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + if (isset($awgParams[$key])) { + $config .= "{$key} = {$awgParams[$key]}\n"; + } + } + + $config .= "\n[Peer]\n"; + $config .= "PublicKey = {$serverPublicKey}\n"; + $config .= "PresharedKey = {$presharedKey}\n"; + $config .= "Endpoint = {$serverHost}:{$serverPort}\n"; + $config .= "AllowedIPs = 0.0.0.0/0, ::/0\n"; + $config .= "PersistentKeepalive = 25\n"; + + return $config; + } + + /** + * Add client to server using official method (append + wg syncconf) + */ + private static function addClientToServer(array $serverData, string $publicKey, string $clientIP): void { + $containerName = $serverData['container_name']; + + // Build peer block + $peerBlock = "\n[Peer]\n"; + $peerBlock .= "PublicKey = {$publicKey}\n"; + $peerBlock .= "PresharedKey = {$serverData['preshared_key']}\n"; + $peerBlock .= "AllowedIPs = {$clientIP}/32\n"; + + $escaped = addslashes($peerBlock); + $tempFile = '/tmp/' . bin2hex(random_bytes(8)) . '.tmp'; + + // Create temp file + $cmd1 = sprintf("docker exec -i %s sh -c 'echo \"%s\" > %s'", $containerName, $escaped, $tempFile); + self::executeServerCommand($serverData, $cmd1, true); + + // Append to wg0.conf + $cmd2 = sprintf("docker exec -i %s sh -c 'cat %s >> /opt/amnezia/awg/wg0.conf'", $containerName, $tempFile); + self::executeServerCommand($serverData, $cmd2, true); + + // Apply via wg syncconf + $cmd3 = sprintf("docker exec -i %s bash -c 'wg syncconf wg0 <(wg-quick strip /opt/amnezia/awg/wg0.conf)'", $containerName); + self::executeServerCommand($serverData, $cmd3, true); + + // Remove temp file + $cmd4 = sprintf("docker exec -i %s rm -f %s", $containerName, $tempFile); + self::executeServerCommand($serverData, $cmd4, true); + + // Update clientsTable + self::updateClientsTable($serverData, $publicKey, $clientIP); + } + + /** + * Update clientsTable on server + */ + private static function updateClientsTable(array $serverData, string $publicKey, string $name): void { + $containerName = $serverData['container_name']; + + // Read current table + $cmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/clientsTable 2>/dev/null", $containerName); + $tableJson = self::executeServerCommand($serverData, $cmd, true); + $table = json_decode(trim($tableJson), true); + + if (!is_array($table)) { + $table = []; + } + + // Add new client + $table[] = [ + 'clientId' => $publicKey, + 'userData' => [ + 'clientName' => $name, + 'creationDate' => date('D M j H:i:s Y') + ] + ]; + + // Save back + $newTableJson = json_encode($table, JSON_PRETTY_PRINT); + $escaped = addslashes($newTableJson); + $updateCmd = sprintf("docker exec -i %s sh -c 'echo \"%s\" > /opt/amnezia/awg/clientsTable'", $containerName, $escaped); + self::executeServerCommand($serverData, $updateCmd, true); + } + + /** + * Execute command on server + */ + private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string { + if ($sudo && strtolower($serverData['username']) !== 'root') { + $command = "echo '{$serverData['password']}' | sudo -S " . $command; + } + + $escapedCommand = escapeshellarg($command); + $sshCommand = sprintf( + "sshpass -p '%s' ssh -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", + $serverData['password'], + $serverData['username'], + $serverData['host'], + $escapedCommand + ); + + return shell_exec($sshCommand) ?? ''; + } + + /** + * Generate QR code for configuration using Amnezia format + * Uses working QrUtil from /Users/oleg/Documents/amnezia + */ + private static function generateQRCode(string $config): string { + require_once __DIR__ . '/QrUtil.php'; + + try { + // Use old Amnezia format with Qt/QDataStream encoding + $payloadOld = QrUtil::encodeOldPayloadFromConf($config); + $dataUri = QrUtil::pngBase64($payloadOld); + return $dataUri; + } catch (Throwable $e) { + error_log('Failed to generate QR code: ' . $e->getMessage()); + return ''; // QR code generation failed, but continue + } + } + + /** + * Get all clients for a server + */ + public static function listByServer(int $serverId): array { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM vpn_clients WHERE server_id = ? ORDER BY created_at DESC'); + $stmt->execute([$serverId]); + return $stmt->fetchAll(); + } + + /** + * Get all clients for a user + */ + public static function listByUser(int $userId): array { + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + SELECT c.*, s.name as server_name, s.host as server_host + FROM vpn_clients c + LEFT JOIN vpn_servers s ON c.server_id = s.id + WHERE c.user_id = ? + ORDER BY c.created_at DESC + '); + $stmt->execute([$userId]); + return $stmt->fetchAll(); + } + + /** + * Revoke client access (disable without deleting) + */ + public function revoke(): bool { + if (!$this->data) { + throw new Exception('Client not loaded'); + } + + // Remove from server + $server = new VpnServer($this->data['server_id']); + $serverData = $server->getData(); + + if ($serverData && $serverData['status'] === 'active') { + try { + self::removeClientFromServer($serverData, $this->data['public_key']); + } catch (Exception $e) { + error_log('Failed to remove client from server: ' . $e->getMessage()); + } + } + + // Mark as disabled in database + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_clients SET status = ? WHERE id = ?'); + return $stmt->execute(['disabled', $this->clientId]); + } + + /** + * Restore client access + */ + public function restore(): bool { + if (!$this->data) { + throw new Exception('Client not loaded'); + } + + // Re-add to server + $server = new VpnServer($this->data['server_id']); + $serverData = $server->getData(); + + if ($serverData && $serverData['status'] === 'active') { + try { + self::addClientToServer($serverData, $this->data['public_key'], $this->data['client_ip']); + } catch (Exception $e) { + throw new Exception('Failed to restore client on server: ' . $e->getMessage()); + } + } + + // Mark as active in database + $pdo = DB::conn(); + $stmt = $pdo->prepare('UPDATE vpn_clients SET status = ? WHERE id = ?'); + return $stmt->execute(['active', $this->clientId]); + } + + /** + * Delete client permanently + */ + public function delete(): bool { + if (!$this->data) { + throw new Exception('Client not loaded'); + } + + // First revoke to remove from server + if ($this->data['status'] === 'active') { + $this->revoke(); + } + + // Delete from database + $pdo = DB::conn(); + $stmt = $pdo->prepare('DELETE FROM vpn_clients WHERE id = ?'); + return $stmt->execute([$this->clientId]); + } + + /** + * Remove client from server WireGuard configuration + */ + private static function removeClientFromServer(array $serverData, string $publicKey): void { + $containerName = $serverData['container_name']; + + // First, remove using wg command (live removal) + $removeCmd = sprintf( + "docker exec -i %s wg set wg0 peer %s remove", + $containerName, + escapeshellarg($publicKey) + ); + + self::executeServerCommand($serverData, $removeCmd, true); + + // Then remove from wg0.conf file to make it persistent + // Use a more reliable method: read, filter, write + $readCmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/wg0.conf", $containerName); + $config = self::executeServerCommand($serverData, $readCmd, true); + + // Parse and remove the peer section + $newConfig = self::removePeerFromConfig($config, $publicKey); + + // Write back to file + $escapedConfig = str_replace("'", "'\\''", $newConfig); + $writeCmd = sprintf( + "docker exec -i %s sh -c 'echo '\''%s'\'' > /opt/amnezia/awg/wg0.conf'", + $containerName, + $escapedConfig + ); + + self::executeServerCommand($serverData, $writeCmd, true); + + // Save config + $saveCmd = sprintf("docker exec -i %s wg-quick save wg0", $containerName); + self::executeServerCommand($serverData, $saveCmd, true); + + // Remove from clientsTable + self::removeFromClientsTable($serverData, $publicKey); + } + + /** + * Remove peer section from WireGuard config + */ + private static function removePeerFromConfig(string $config, string $publicKey): string { + $lines = explode("\n", $config); + $newLines = []; + $inPeerBlock = false; + $skipBlock = false; + + foreach ($lines as $line) { + $trimmed = trim($line); + + // Start of new section + if (strpos($trimmed, '[') === 0) { + $inPeerBlock = ($trimmed === '[Peer]'); + $skipBlock = false; + } + + // Check if this peer block should be skipped + if ($inPeerBlock && strpos($trimmed, 'PublicKey') === 0) { + $parts = explode('=', $line, 2); + if (count($parts) === 2 && trim($parts[1]) === $publicKey) { + $skipBlock = true; + // Remove the [Peer] line that was already added + array_pop($newLines); + continue; + } + } + + // Skip lines in the block to be removed + if ($skipBlock && $inPeerBlock) { + // Empty line ends the peer block + if (empty($trimmed)) { + $skipBlock = false; + $inPeerBlock = false; + } + continue; + } + + $newLines[] = $line; + } + + return implode("\n", $newLines); + } + + /** + * Remove client from clientsTable + */ + private static function removeFromClientsTable(array $serverData, string $publicKey): void { + $containerName = $serverData['container_name']; + + // Read current table + $cmd = sprintf("docker exec -i %s cat /opt/amnezia/awg/clientsTable 2>/dev/null", $containerName); + $tableJson = self::executeServerCommand($serverData, $cmd, true); + $table = json_decode(trim($tableJson), true); + + if (!is_array($table)) { + return; + } + + // Filter out the client + $table = array_filter($table, function($client) use ($publicKey) { + return ($client['clientId'] ?? '') !== $publicKey; + }); + + // Re-index array + $table = array_values($table); + + // Save back + $newTableJson = json_encode($table, JSON_PRETTY_PRINT); + $escaped = addslashes($newTableJson); + $updateCmd = sprintf("docker exec -i %s sh -c 'echo \"%s\" > /opt/amnezia/awg/clientsTable'", $containerName, $escaped); + self::executeServerCommand($serverData, $updateCmd, true); + } + + /** + * Get client data + */ + public function getData(): ?array { + return $this->data; + } + + /** + * Get configuration file content + */ + public function getConfig(): string { + return $this->data['config'] ?? ''; + } + + /** + * Get QR code + */ + public function getQRCode(): string { + return $this->data['qr_code'] ?? ''; + } + + /** + * Sync traffic statistics from server + */ + public function syncStats(): bool { + if (!$this->data) { + throw new Exception('Client not loaded'); + } + + $server = new VpnServer($this->data['server_id']); + $serverData = $server->getData(); + + if (!$serverData || $serverData['status'] !== 'active') { + return false; + } + + try { + $stats = self::getClientStatsFromServer($serverData, $this->data['public_key']); + + $pdo = DB::conn(); + $stmt = $pdo->prepare(' + UPDATE vpn_clients + SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, last_sync_at = NOW() + WHERE id = ? + '); + + $lastHandshake = $stats['last_handshake'] > 0 + ? date('Y-m-d H:i:s', $stats['last_handshake']) + : null; + + return $stmt->execute([ + $stats['bytes_sent'], + $stats['bytes_received'], + $lastHandshake, + $this->clientId + ]); + } catch (Exception $e) { + error_log('Failed to sync client stats: ' . $e->getMessage()); + return false; + } + } + + /** + * Get client statistics from server + */ + private static function getClientStatsFromServer(array $serverData, string $publicKey): array { + $containerName = $serverData['container_name']; + + // Get WireGuard interface stats + $cmd = sprintf("docker exec -i %s wg show wg0 dump", $containerName); + $output = self::executeServerCommand($serverData, $cmd, true); + + $stats = [ + 'bytes_sent' => 0, + 'bytes_received' => 0, + 'last_handshake' => 0 + ]; + + // Parse wg dump output + // Format: public_key preshared_key endpoint allowed_ips latest_handshake transfer_rx transfer_tx persistent_keepalive + // First line is server (private key), skip it + // For clients: transfer_rx = bytes received by server (sent by client) + // transfer_tx = bytes sent by server (received by client) + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + if (empty($line)) continue; + + $parts = preg_split('/\s+/', trim($line)); + + // Skip first line (server) - it has different format + if (count($parts) < 7) continue; + + // Match by public key + if ($parts[0] === $publicKey) { + $stats['last_handshake'] = (int)$parts[4]; + $stats['bytes_sent'] = (int)$parts[5]; // transfer_rx - client sent + $stats['bytes_received'] = (int)$parts[6]; // transfer_tx - client received + break; + } + } + + return $stats; + } + + /** + * Sync stats for all active clients on a server + */ + public static function syncAllStatsForServer(int $serverId): int { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND status = ?'); + $stmt->execute([$serverId, 'active']); + $clientIds = $stmt->fetchAll(PDO::FETCH_COLUMN); + + $synced = 0; + foreach ($clientIds as $clientId) { + try { + $client = new VpnClient($clientId); + if ($client->syncStats()) { + $synced++; + } + } catch (Exception $e) { + error_log('Failed to sync stats for client ' . $clientId . ': ' . $e->getMessage()); + } + } + + return $synced; + } + + /** + * Get human-readable traffic statistics + */ + public function getFormattedStats(): array { + if (!$this->data) { + return ['sent' => 'N/A', 'received' => 'N/A', 'total' => 'N/A', 'last_seen' => 'Never']; + } + + $sent = $this->formatBytes($this->data['bytes_sent'] ?? 0); + $received = $this->formatBytes($this->data['bytes_received'] ?? 0); + $total = $this->formatBytes(($this->data['bytes_sent'] ?? 0) + ($this->data['bytes_received'] ?? 0)); + + $lastSeen = 'Never'; + if (!empty($this->data['last_handshake'])) { + $lastHandshake = strtotime($this->data['last_handshake']); + $diff = time() - $lastHandshake; + + if ($diff < 300) { + $lastSeen = 'Online'; + } elseif ($diff < 3600) { + $lastSeen = floor($diff / 60) . ' minutes ago'; + } elseif ($diff < 86400) { + $lastSeen = floor($diff / 3600) . ' hours ago'; + } else { + $lastSeen = floor($diff / 86400) . ' days ago'; + } + } + + return [ + 'sent' => $sent, + 'received' => $received, + 'total' => $total, + 'last_seen' => $lastSeen, + 'is_online' => !empty($this->data['last_handshake']) && (time() - strtotime($this->data['last_handshake'])) < 300 + ]; + } + + /** + * Format bytes to human-readable string + */ + private function formatBytes(int $bytes): string { + if ($bytes === 0) return '0 B'; + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = floor(log($bytes) / log(1024)); + + return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i]; + } +} diff --git a/inc/VpnServer.php b/inc/VpnServer.php new file mode 100644 index 0000000..88ebed8 --- /dev/null +++ b/inc/VpnServer.php @@ -0,0 +1,449 @@ +serverId = $serverId; + if ($serverId) { + $this->load(); + } + } + + /** + * Load server data from database + */ + private function load(): void { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM vpn_servers WHERE id = ?'); + $stmt->execute([$this->serverId]); + $this->data = $stmt->fetch(); + if (!$this->data) { + throw new Exception('Server not found'); + } + } + + /** + * Create new VPN server in database + */ + public static function create(array $data): int { + $pdo = DB::conn(); + + // Validate required fields + $required = ['user_id', 'name', 'host', 'port', 'username', 'password']; + foreach ($required as $field) { + if (empty($data[$field])) { + throw new Exception("Field {$field} is required"); + } + } + + $stmt = $pdo->prepare(' + INSERT INTO vpn_servers + (user_id, name, host, port, username, password, container_name, vpn_subnet, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + + $stmt->execute([ + $data['user_id'], + $data['name'], + $data['host'], + $data['port'], + $data['username'], + $data['password'], + $data['container_name'] ?? 'amnezia-awg', + $data['vpn_subnet'] ?? '10.8.1.0/24', + 'deploying' + ]); + + return (int)$pdo->lastInsertId(); + } + + /** + * Deploy VPN server using amnezia_deploy_v2.php logic + */ + public function deploy(): array { + if (!$this->data) { + throw new Exception('Server not loaded'); + } + + $pdo = DB::conn(); + $errors = []; + + try { + // Update status to deploying + $pdo->prepare('UPDATE vpn_servers SET status = ? WHERE id = ?') + ->execute(['deploying', $this->serverId]); + + // Test SSH connection + if (!$this->testConnection()) { + throw new Exception('SSH connection failed'); + } + + // Install Docker if needed + $this->installDocker(); + + // Create directories + $this->executeCommand('mkdir -p /opt/amnezia/amnezia-awg', true); + + // Find free UDP port + $vpnPort = $this->findFreeUdpPort(); + + // Create Dockerfile + $this->createDockerfile(); + + // Create start script + $this->createStartScript(); + + // Build Docker image + $this->buildDockerImage(); + + // Run container + $this->runContainer($vpnPort); + + // Initialize server config + $keys = $this->initializeServerConfig($vpnPort); + + // Update database with deployment info + $stmt = $pdo->prepare(' + UPDATE vpn_servers + SET vpn_port = ?, + server_public_key = ?, + preshared_key = ?, + awg_params = ?, + status = ?, + deployed_at = NOW(), + error_message = NULL + WHERE id = ? + '); + + $stmt->execute([ + $vpnPort, + $keys['public_key'], + $keys['preshared_key'], + json_encode($keys['awg_params']), + 'active', + $this->serverId + ]); + + // Reload data + $this->load(); + + return [ + 'success' => true, + 'vpn_port' => $vpnPort, + 'public_key' => $keys['public_key'] + ]; + + } catch (Exception $e) { + // Update status to error + $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = ? WHERE id = ?') + ->execute(['error', $e->getMessage(), $this->serverId]); + + throw $e; + } + } + + /** + * Test SSH connection to server + */ + private function testConnection(): bool { + $testCommand = sprintf( + "sshpass -p '%s' ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no -o ConnectTimeout=10 %s@%s 'echo test' 2>/dev/null", + $this->data['password'], + $this->data['username'], + $this->data['host'] + ); + + $result = shell_exec($testCommand); + return trim($result) === 'test'; + } + + /** + * Execute command on remote server + */ + private function executeCommand(string $command, bool $sudo = false): string { + if ($sudo && strtolower($this->data['username']) !== 'root') { + $command = "echo '{$this->data['password']}' | sudo -S " . $command; + } + + $escapedCommand = escapeshellarg($command); + $sshCommand = sprintf( + "sshpass -p '%s' ssh -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1", + $this->data['password'], + $this->data['username'], + $this->data['host'], + $escapedCommand + ); + + return shell_exec($sshCommand) ?? ''; + } + + /** + * Install Docker on remote server + */ + private function installDocker(): void { + $dockerVersion = $this->executeCommand('docker --version'); + if (stripos($dockerVersion, 'version') !== false) { + return; // Docker already installed + } + + $this->executeCommand('curl -fsSL https://get.docker.com | sh', true); + $this->executeCommand('systemctl enable --now docker', true); + } + + /** + * Find free UDP port on remote server + */ + private function findFreeUdpPort(): int { + $min = 30000; + $max = 65000; + + for ($attempt = 0; $attempt < 30; $attempt++) { + $candidate = random_int($min, $max); + $cmd = "ss -lun | awk '{print \$4}' | grep -E ':(" . $candidate . ")($| )' || true"; + $out = $this->executeCommand($cmd, false); + if (trim($out) === '') { + return $candidate; + } + } + + throw new Exception('Could not find free UDP port'); + } + + /** + * Create Dockerfile on remote server + */ + private function createDockerfile(): void { + $dockerfile = <<<'DOCKERFILE' +FROM amneziavpn/amnezia-wg:latest + +LABEL maintainer="AmneziaVPN" + +RUN apk add --no-cache bash curl dumb-init +RUN apk --update upgrade --no-cache + +RUN mkdir -p /opt/amnezia +RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh +RUN chmod a+x /opt/amnezia/start.sh + +ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] +CMD [ "" ] +DOCKERFILE; + + $escaped = addslashes(trim($dockerfile)); + $this->executeCommand("echo \"{$escaped}\" > /opt/amnezia/amnezia-awg/Dockerfile", true); + } + + /** + * Create start script on remote server + */ + private function createStartScript(): void { + $script = <<<'BASH' +#!/bin/bash + +echo "Container startup" + +# Wait for config if not exists yet +for i in {1..30}; do + if [ -f /opt/amnezia/awg/wg0.conf ]; then + break + fi + sleep 1 +done + +# Kill daemons in case of restart +wg-quick down /opt/amnezia/awg/wg0.conf 2>/dev/null || true + +# Start daemons if configured +if [ -f /opt/amnezia/awg/wg0.conf ]; then + wg-quick up /opt/amnezia/awg/wg0.conf + echo "WireGuard started" +else + echo "No wg0.conf found, skipping WireGuard startup" +fi + +# Allow traffic on the TUN interface +iptables -A INPUT -i wg0 -j ACCEPT 2>/dev/null || true +iptables -A FORWARD -i wg0 -j ACCEPT 2>/dev/null || true +iptables -A OUTPUT -o wg0 -j ACCEPT 2>/dev/null || true + +# Allow forwarding traffic only from the VPN +iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true +iptables -A FORWARD -i wg0 -o eth1 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true + +iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + +iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true +iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth1 -j MASQUERADE 2>/dev/null || true + +tail -f /dev/null +BASH; + + $escaped = addslashes(trim($script)); + $this->executeCommand("echo \"{$escaped}\" > /opt/amnezia/amnezia-awg/start.sh", true); + $this->executeCommand("chmod +x /opt/amnezia/amnezia-awg/start.sh", true); + } + + /** + * Build Docker image + */ + private function buildDockerImage(): void { + $containerName = $this->data['container_name']; + + // Cleanup old container/image + $this->executeCommand("docker stop {$containerName} 2>/dev/null || true", true); + $this->executeCommand("docker rm -fv {$containerName} 2>/dev/null || true", true); + $this->executeCommand("docker rmi {$containerName} 2>/dev/null || true", true); + + // Build new image + $buildCmd = sprintf( + 'docker build --no-cache --pull -t %s /opt/amnezia/amnezia-awg', + $containerName + ); + $this->executeCommand($buildCmd, true); + } + + /** + * Run Docker container + */ + private function runContainer(int $vpnPort): void { + $containerName = $this->data['container_name']; + + $runCmd = sprintf( + 'docker run -d --log-driver none --restart always --privileged --cap-add=NET_ADMIN --cap-add=SYS_MODULE -p %d:%d/udp -v /lib/modules:/lib/modules --name %s %s', + $vpnPort, + $vpnPort, + $containerName, + $containerName + ); + + $this->executeCommand($runCmd, true); + sleep(3); // Wait for container to start + } + + /** + * Initialize server configuration with AWG parameters + */ + private function initializeServerConfig(int $vpnPort): array { + $containerName = $this->data['container_name']; + + // Create directory + $this->executeCommand("docker exec -i {$containerName} mkdir -p /opt/amnezia/awg", true); + + // Generate keys + $this->executeCommand("docker exec -i {$containerName} sh -c 'cd /opt/amnezia/awg && umask 077 && wg genkey | tee server_private.key | wg pubkey > wireguard_server_public_key.key'", true); + $this->executeCommand("docker exec -i {$containerName} sh -c 'cd /opt/amnezia/awg && wg genpsk > wireguard_psk.key'", true); + $this->executeCommand("docker exec -i {$containerName} chmod 600 /opt/amnezia/awg/server_private.key /opt/amnezia/awg/wireguard_psk.key /opt/amnezia/awg/wireguard_server_public_key.key", true); + + // Get keys + $privKey = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/server_private.key", true)); + $pubKey = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/wireguard_server_public_key.key", true)); + $psk = trim($this->executeCommand("docker exec -i {$containerName} cat /opt/amnezia/awg/wireguard_psk.key", true)); + + // Generate AWG parameters + $awgParams = [ + 'Jc' => 3, + 'Jmin' => 10, + 'Jmax' => 50, + 'S1' => rand(50, 250), + 'S2' => rand(50, 250), + 'H1' => rand(100000, 2000000000), + 'H2' => rand(100000, 2000000000), + 'H3' => rand(100000, 2000000000), + 'H4' => rand(100000, 2000000000) + ]; + + // Create wg0.conf + $wgConfig = "[Interface]\n"; + $wgConfig .= "PrivateKey = {$privKey}\n"; + $wgConfig .= "Address = {$this->data['vpn_subnet']}\n"; + $wgConfig .= "ListenPort = {$vpnPort}\n"; + foreach ($awgParams as $key => $value) { + $wgConfig .= "{$key} = {$value}\n"; + } + $wgConfig .= "\n"; + + $escaped = addslashes($wgConfig); + $this->executeCommand("docker exec -i {$containerName} sh -c 'echo \"{$escaped}\" > /opt/amnezia/awg/wg0.conf'", true); + $this->executeCommand("docker exec -i {$containerName} chmod 600 /opt/amnezia/awg/wg0.conf", true); + + // Create clientsTable + $this->executeCommand("docker exec -i {$containerName} sh -c 'echo \"[]\" > /opt/amnezia/awg/clientsTable'", true); + + // Start WireGuard + $this->executeCommand("docker exec -i {$containerName} wg-quick up /opt/amnezia/awg/wg0.conf 2>&1", true); + + // Apply firewall rules + $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A INPUT -i wg0 -j ACCEPT 2>/dev/null || true'", true); + $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -j ACCEPT 2>/dev/null || true'", true); + $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A OUTPUT -o wg0 -j ACCEPT 2>/dev/null || true'", true); + $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true'", true); + $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true'", true); + + sleep(2); + + return [ + 'public_key' => $pubKey, + 'preshared_key' => $psk, + 'awg_params' => $awgParams + ]; + } + + /** + * Get server status from database + */ + public function getStatus(): string { + return $this->data['status'] ?? 'unknown'; + } + + /** + * Get all servers for a user + */ + public static function listByUser(int $userId): array { + $pdo = DB::conn(); + $stmt = $pdo->prepare('SELECT * FROM vpn_servers WHERE user_id = ? ORDER BY created_at DESC'); + $stmt->execute([$userId]); + return $stmt->fetchAll(); + } + + /** + * Get all servers (admin only) + */ + public static function listAll(): array { + $pdo = DB::conn(); + $stmt = $pdo->query('SELECT s.*, u.email as user_email FROM vpn_servers s LEFT JOIN users u ON s.user_id = u.id ORDER BY s.created_at DESC'); + return $stmt->fetchAll(); + } + + /** + * Delete server + */ + public function delete(): bool { + // Stop and remove container + try { + $containerName = $this->data['container_name']; + $this->executeCommand("docker stop {$containerName} 2>/dev/null || true", true); + $this->executeCommand("docker rm -fv {$containerName} 2>/dev/null || true", true); + $this->executeCommand("rm -rf /opt/amnezia/amnezia-awg", true); + } catch (Exception $e) { + // Ignore errors during cleanup + } + + // Delete from database + $pdo = DB::conn(); + $stmt = $pdo->prepare('DELETE FROM vpn_servers WHERE id = ?'); + return $stmt->execute([$this->serverId]); + } + + /** + * Get server data + */ + public function getData(): ?array { + return $this->data; + } +} diff --git a/migrations/init.sql b/migrations/init.sql new file mode 100644 index 0000000..cd8e06b --- /dev/null +++ b/migrations/init.sql @@ -0,0 +1,242 @@ +-- Amnezia VPN Panel - Complete Database Schema +-- Single migration file containing all tables and initial data + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') DEFAULT 'user', + preferred_language VARCHAR(10) DEFAULT 'en', + status ENUM('active', 'disabled') DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP NULL, + INDEX idx_email (email), + INDEX idx_role (role), + INDEX idx_language (preferred_language) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- VPN Servers table +CREATE TABLE IF NOT EXISTS vpn_servers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + host VARCHAR(255) NOT NULL, + port INT UNSIGNED NOT NULL, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + container_name VARCHAR(255) DEFAULT 'amnezia-awg', + vpn_port INT UNSIGNED NULL, + vpn_subnet VARCHAR(50) DEFAULT '10.8.1.0/24', + server_public_key TEXT NULL, + preshared_key TEXT NULL, + awg_params JSON NULL COMMENT 'Jc, Jmin, Jmax, S1, S2, H1-H4', + status ENUM('deploying', 'active', 'stopped', 'error') DEFAULT 'deploying', + deployed_at TIMESTAMP NULL, + last_check_at TIMESTAMP NULL, + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_status (status), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- VPN Clients table +CREATE TABLE IF NOT EXISTS vpn_clients ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + server_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + client_ip VARCHAR(50) NOT NULL, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + preshared_key TEXT NULL, + config TEXT NULL COMMENT 'Full WireGuard config file', + qr_code LONGTEXT NULL COMMENT 'Base64 encoded QR code image', + bytes_sent BIGINT UNSIGNED DEFAULT 0 COMMENT 'Total bytes sent by client', + bytes_received BIGINT UNSIGNED DEFAULT 0 COMMENT 'Total bytes received by client', + last_handshake TIMESTAMP NULL COMMENT 'Last successful WireGuard handshake', + last_sync_at TIMESTAMP NULL COMMENT 'Last time stats were synced from server', + status ENUM('active', 'disabled') DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_server_id (server_id), + INDEX idx_user_id (user_id), + INDEX idx_status (status), + INDEX idx_last_handshake (last_handshake), + UNIQUE KEY unique_server_client_ip (server_id, client_ip), + FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- API Tokens table +CREATE TABLE IF NOT EXISTS api_tokens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + last_used_at TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + revoked_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_token (token), + INDEX idx_user_id (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Settings table +CREATE TABLE IF NOT EXISTS settings ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NULL COMMENT 'NULL for global settings', + namespace VARCHAR(100) NOT NULL, + `key` VARCHAR(100) NOT NULL, + value JSON NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_setting (user_id, namespace, `key`), + INDEX idx_namespace (namespace) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Languages table +CREATE TABLE IF NOT EXISTS languages ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(10) NOT NULL UNIQUE COMMENT 'Language code (en, ru, es, de, fr, zh)', + name VARCHAR(50) NOT NULL COMMENT 'Language name in English', + native_name VARCHAR(50) NOT NULL COMMENT 'Language name in native language', + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_code (code), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Translations table +CREATE TABLE IF NOT EXISTS translations ( + id INT AUTO_INCREMENT PRIMARY KEY, + language_code VARCHAR(10) NOT NULL, + translation_key VARCHAR(255) NOT NULL COMMENT 'Translation key (e.g., menu.dashboard)', + translation_value TEXT NOT NULL COMMENT 'Translated text', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_translation (language_code, translation_key), + FOREIGN KEY (language_code) REFERENCES languages(code) ON DELETE CASCADE, + INDEX idx_key (translation_key), + INDEX idx_language (language_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- API Keys table +CREATE TABLE IF NOT EXISTS api_keys ( + id INT AUTO_INCREMENT PRIMARY KEY, + service_name VARCHAR(50) NOT NULL UNIQUE COMMENT 'Service name (e.g., openrouter)', + api_key TEXT NOT NULL COMMENT 'API key value', + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_service (service_name), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default admin user +INSERT IGNORE INTO users (email, password_hash, name, role, status) +VALUES ('admin@amnez.ia', '$2y$10$SKEI6ogiWr2gsSG/nELLp.JcfpGhxsDLAAI7gdtTOI3ELz4zJzzPG', 'Administrator', 'admin', 'active'); + +-- Insert supported languages +INSERT INTO languages (code, name, native_name) VALUES +('en', 'English', 'English'), +('ru', 'Russian', 'Русский'), +('es', 'Spanish', 'Español'), +('de', 'German', 'Deutsch'), +('fr', 'French', 'Français'), +('zh', 'Chinese', '中文') +ON DUPLICATE KEY UPDATE name=VALUES(name); + +-- Insert English translations +INSERT INTO translations (language_code, translation_key, translation_value) VALUES +('en', 'auth.email', 'Email'), +('en', 'auth.login', 'Login'), +('en', 'auth.name', 'Name'), +('en', 'auth.password', 'Password'), +('en', 'auth.register', 'Register'), +('en', 'clients.actions', 'Actions'), +('en', 'clients.add', 'Add Client'), +('en', 'clients.delete', 'Delete'), +('en', 'clients.download_config', 'Download Config'), +('en', 'clients.ip', 'IP Address'), +('en', 'clients.last_handshake', 'Last Handshake'), +('en', 'clients.name', 'Client Name'), +('en', 'clients.qr_code', 'QR Code'), +('en', 'clients.received', 'Received'), +('en', 'clients.restore', 'Restore'), +('en', 'clients.revoke', 'Revoke'), +('en', 'clients.sent', 'Sent'), +('en', 'clients.server', 'Server'), +('en', 'clients.status', 'Status'), +('en', 'clients.sync_stats', 'Sync Stats'), +('en', 'clients.title', 'Clients'), +('en', 'clients.traffic', 'Traffic'), +('en', 'dashboard.active_clients', 'Active Clients'), +('en', 'dashboard.add_first_server', 'Add First Server'), +('en', 'dashboard.get_started', 'Get started by adding your first VPN server'), +('en', 'dashboard.no_servers', 'No servers yet'), +('en', 'dashboard.quick_actions', 'Quick Actions'), +('en', 'dashboard.recent_servers', 'Recent Servers'), +('en', 'dashboard.title', 'Dashboard'), +('en', 'dashboard.total_clients', 'Total Clients'), +('en', 'dashboard.total_servers', 'Total Servers'), +('en', 'dashboard.total_traffic', 'Total Traffic'), +('en', 'dashboard.welcome', 'Welcome to Amnezia VPN Management Panel'), +('en', 'form.cancel', 'Cancel'), +('en', 'form.close', 'Close'), +('en', 'form.loading', 'Loading...'), +('en', 'form.processing', 'Processing...'), +('en', 'form.save', 'Save'), +('en', 'form.submit', 'Submit'), +('en', 'form.update', 'Update'), +('en', 'menu.clients', 'Clients'), +('en', 'menu.dashboard', 'Dashboard'), +('en', 'menu.logout', 'Logout'), +('en', 'menu.servers', 'Servers'), +('en', 'menu.settings', 'Settings'), +('en', 'menu.users', 'Users'), +('en', 'message.confirm', 'Are you sure?'), +('en', 'message.deleted', 'Deleted successfully'), +('en', 'message.deployed', 'Deployed successfully'), +('en', 'message.error', 'An error occurred'), +('en', 'message.saved', 'Saved successfully'), +('en', 'message.success', 'Operation completed successfully'), +('en', 'servers.actions', 'Actions'), +('en', 'servers.add', 'Add Server'), +('en', 'servers.clients', 'Clients'), +('en', 'servers.delete', 'Delete'), +('en', 'servers.deploy', 'Deploy'), +('en', 'servers.edit', 'Edit'), +('en', 'servers.host', 'Host'), +('en', 'servers.name', 'Name'), +('en', 'servers.port', 'Port'), +('en', 'servers.status', 'Status'), +('en', 'servers.title', 'Servers'), +('en', 'servers.view', 'View'), +('en', 'settings.actions', 'Actions'), +('en', 'settings.api_keys', 'API Keys'), +('en', 'settings.api_keys_desc', 'Configure API keys for external services'), +('en', 'settings.auto_translate', 'Auto-translate'), +('en', 'settings.confirm_translate', 'Start automatic translation? This may take a few minutes.'), +('en', 'settings.description', 'Manage panel configuration and API integrations'), +('en', 'settings.error_empty_key', 'API key cannot be empty'), +('en', 'settings.error_invalid_key', 'Invalid API key format'), +('en', 'settings.error_key_test', 'API key test failed'), +('en', 'settings.for_translation', 'for auto-translation'), +('en', 'settings.get_key_at', 'Get your API key at'), +('en', 'settings.key_saved', 'API key saved successfully'), +('en', 'settings.keys', 'keys'), +('en', 'settings.language', 'Language'), +('en', 'settings.progress', 'Progress'), +('en', 'settings.translation_complete', 'Translation completed'), +('en', 'settings.translation_status', 'Translation Status'), +('en', 'status.active', 'Active'), +('en', 'status.deploying', 'Deploying'), +('en', 'status.disabled', 'Disabled'), +('en', 'status.error', 'Error'), +('en', 'status.inactive', 'Inactive') +ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..3aec5e2 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9b67e14 --- /dev/null +++ b/public/index.php @@ -0,0 +1,1110 @@ +getMessage()); +} + +// Seed admin user if not exists +try { + $adminEmail = Config::get('ADMIN_EMAIL'); + $adminPass = Config::get('ADMIN_PASSWORD'); + if ($adminEmail && $adminPass) { + Auth::seedAdmin($adminEmail, $adminPass); + } +} catch (Throwable $e) { + // Ignore errors +} + +// Initialize translator +Translator::init(); + +// Initialize template engine +$user = Auth::user(); +$appName = Config::get('APP_NAME', 'Amnezia VPN Panel'); + +View::init(__DIR__ . '/../templates', [ + 'app_name' => $appName, + 'user' => $user, + 'current_language' => Translator::getCurrentLanguage(), + 'languages' => Translator::getSupportedLanguages(), + 'current_uri' => $_SERVER['REQUEST_URI'] ?? '/dashboard', + 't' => function($key, $params = []) { + return Translator::t($key, $params); + } +]); + +// Helper function for redirects +function redirect(string $to): void { + header('Location: ' . $to); + exit; +} + +// Helper function to require authentication +function requireAuth(): void { + if (!Auth::check()) { + redirect('/login'); + } +} + +// Helper function to require admin +function requireAdmin(): void { + requireAuth(); + if (!Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden: Admin access required'; + exit; + } +} + +/** + * PUBLIC ROUTES + */ + +// Home page +Router::get('/', function () { + if (!Auth::check()) { + redirect('/login'); + } + redirect('/dashboard'); +}); + +// Login page +Router::get('/login', function () { + if (Auth::check()) { + redirect('/dashboard'); + } + View::render('login.twig'); +}); + +Router::post('/login', function () { + $email = trim($_POST['email'] ?? ''); + $password = $_POST['password'] ?? ''; + + if (Auth::login($email, $password)) { + redirect('/dashboard'); + } + + View::render('login.twig', ['error' => 'Invalid credentials']); +}); + +// Register page +Router::get('/register', function () { + if (Auth::check()) { + redirect('/dashboard'); + } + View::render('register.twig'); +}); + +Router::post('/register', function () { + $name = trim($_POST['name'] ?? ''); + $email = trim($_POST['email'] ?? ''); + $password = $_POST['password'] ?? ''; + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + View::render('register.twig', ['error' => 'Invalid email address']); + return; + } + + if (strlen($password) < 6) { + View::render('register.twig', ['error' => 'Password must be at least 6 characters']); + return; + } + + try { + $success = Auth::register($name, $email, $password); + if ($success) { + Auth::login($email, $password); + redirect('/dashboard'); + } + } catch (Throwable $e) { + // Email already exists or other error + } + + View::render('register.twig', ['error' => 'Registration failed. Email may already be in use.']); +}); + +// Logout +Router::get('/logout', function () { + Auth::logout(); + redirect('/login'); +}); + +/** + * AUTHENTICATED ROUTES + */ + +// Dashboard +Router::get('/dashboard', function () { + requireAuth(); + $user = Auth::user(); + + // Get user's servers + $servers = VpnServer::listByUser($user['id']); + + // Get user's clients + $clients = VpnClient::listByUser($user['id']); + + View::render('dashboard.twig', [ + 'servers' => $servers, + 'clients' => $clients, + ]); +}); + +// Servers list +Router::get('/servers', function () { + requireAuth(); + $user = Auth::user(); + + $servers = Auth::isAdmin() + ? VpnServer::listAll() + : VpnServer::listByUser($user['id']); + + View::render('servers/index.twig', ['servers' => $servers]); +}); + +// Create server page +Router::get('/servers/create', function () { + requireAuth(); + View::render('servers/create.twig'); +}); + +// Create server action +Router::post('/servers/create', function () { + requireAuth(); + $user = Auth::user(); + + $name = trim($_POST['name'] ?? ''); + $host = trim($_POST['host'] ?? ''); + $port = (int)($_POST['port'] ?? 22); + $username = trim($_POST['username'] ?? 'root'); + $password = $_POST['password'] ?? ''; + + if (empty($name) || empty($host) || empty($password)) { + View::render('servers/create.twig', ['error' => 'All fields are required']); + return; + } + + try { + $serverId = VpnServer::create([ + 'user_id' => $user['id'], + 'name' => $name, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + ]); + + redirect('/servers/' . $serverId . '/deploy'); + } catch (Exception $e) { + View::render('servers/create.twig', ['error' => $e->getMessage()]); + } +}); + +// Delete server action +Router::post('/servers/{id}/delete', function ($params) { + requireAuth(); + $user = Auth::user(); + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership or admin + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + $server->delete(); + $_SESSION['success_message'] = 'Server deleted successfully'; + redirect('/servers'); + } catch (Exception $e) { + $_SESSION['error_message'] = $e->getMessage(); + redirect('/servers'); + } +}); + +// Deploy server page +Router::get('/servers/{id}/deploy', function ($params) { + requireAuth(); + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + View::render('servers/deploy.twig', ['server' => $serverData]); + } catch (Exception $e) { + http_response_code(404); + echo 'Server not found'; + } +}); + +// Deploy server action (AJAX) +Router::post('/servers/{id}/deploy', function ($params) { + requireAuth(); + header('Content-Type: application/json'); + + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $result = $server->deploy(); + echo json_encode($result); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// View server +Router::get('/servers/{id}', function ($params) { + requireAuth(); + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + // Get clients for this server + $clients = VpnClient::listByServer($serverId); + + View::render('servers/view.twig', [ + 'server' => $serverData, + 'clients' => $clients, + ]); + } catch (Exception $e) { + http_response_code(404); + echo 'Server not found'; + } +}); + +// Delete server +Router::post('/servers/{id}/delete', function ($params) { + requireAuth(); + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + $server->delete(); + redirect('/servers'); + } catch (Exception $e) { + redirect('/servers'); + } +}); + +// Create client for server +Router::post('/servers/{id}/clients/create', function ($params) { + requireAuth(); + $serverId = (int)$params['id']; + $clientName = trim($_POST['name'] ?? ''); + + if (empty($clientName)) { + redirect('/servers/' . $serverId . '?error=Client+name+is+required'); + return; + } + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + $clientId = VpnClient::create($serverId, $user['id'], $clientName); + redirect('/clients/' . $clientId); + } catch (Exception $e) { + redirect('/servers/' . $serverId . '?error=' . urlencode($e->getMessage())); + } +}); + +// View client +Router::get('/clients/{id}', function ($params) { + requireAuth(); + $clientId = (int)$params['id']; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + $user = Auth::user(); + if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + View::render('clients/view.twig', ['client' => $clientData]); + } catch (Exception $e) { + http_response_code(404); + echo 'Client not found'; + } +}); + +// Download client config +Router::get('/clients/{id}/download', function ($params) { + requireAuth(); + $clientId = (int)$params['id']; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + $user = Auth::user(); + if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + $config = $client->getConfig(); + $filename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $clientData['name']) . '.conf'; + + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . strlen($config)); + echo $config; + } catch (Exception $e) { + http_response_code(404); + echo 'Client not found'; + } +}); + +// Revoke client access +Router::post('/clients/{id}/revoke', function ($params) { + requireAuth(); + $clientId = (int)$params['id']; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + $user = Auth::user(); + if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + if ($client->revoke()) { + redirect('/servers/' . $clientData['server_id'] . '?success=Client+revoked'); + } else { + redirect('/servers/' . $clientData['server_id'] . '?error=Failed+to+revoke+client'); + } + } catch (Exception $e) { + redirect('/dashboard?error=' . urlencode($e->getMessage())); + } +}); + +// Restore client access +Router::post('/clients/{id}/restore', function ($params) { + requireAuth(); + $clientId = (int)$params['id']; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + $user = Auth::user(); + if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + if ($client->restore()) { + redirect('/servers/' . $clientData['server_id'] . '?success=Client+restored'); + } else { + redirect('/servers/' . $clientData['server_id'] . '?error=Failed+to+restore+client'); + } + } catch (Exception $e) { + redirect('/dashboard?error=' . urlencode($e->getMessage())); + } +}); + +// Delete client +Router::post('/clients/{id}/delete', function ($params) { + requireAuth(); + $clientId = (int)$params['id']; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + $user = Auth::user(); + if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + $serverId = $clientData['server_id']; + + if ($client->delete()) { + redirect('/servers/' . $serverId . '?success=Client+deleted'); + } else { + redirect('/servers/' . $serverId . '?error=Failed+to+delete+client'); + } + } catch (Exception $e) { + redirect('/dashboard?error=' . urlencode($e->getMessage())); + } +}); + +// Sync client stats +Router::post('/clients/{id}/sync-stats', function ($params) { + requireAuth(); + $clientId = (int)$params['id']; + + header('Content-Type: application/json'); + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Check ownership + $user = Auth::user(); + if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + if ($client->syncStats()) { + // Reload client data + $client = new VpnClient($clientId); + $stats = $client->getFormattedStats(); + echo json_encode(['success' => true, 'stats' => $stats]); + } else { + echo json_encode(['success' => false, 'error' => 'Failed to sync stats']); + } + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// Sync all stats for server +Router::post('/servers/{id}/sync-stats', function ($params) { + requireAuth(); + $serverId = (int)$params['id']; + + header('Content-Type: application/json'); + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $synced = VpnClient::syncAllStatsForServer($serverId); + echo json_encode(['success' => true, 'synced' => $synced]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +/** + * API ROUTES (for Telegram bot integration) + */ + +// API: Generate JWT token +Router::post('/api/auth/token', function () { + header('Content-Type: application/json'); + + $email = $_POST['email'] ?? ''; + $password = $_POST['password'] ?? ''; + + if (empty($email) || empty($password)) { + http_response_code(400); + echo json_encode(['error' => 'Email and password are required']); + return; + } + + $user = Auth::getUserByEmail($email); + if (!$user || !password_verify($password, $user['password_hash'])) { + http_response_code(401); + echo json_encode(['error' => 'Invalid credentials']); + return; + } + + try { + $token = JWT::generate($user['id']); + echo json_encode([ + 'success' => true, + 'token' => $token, + 'type' => 'Bearer', + 'expires_in' => 30 * 24 * 3600 // 30 days + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => 'Token generation failed']); + } +}); + +// API: Create persistent API token +Router::post('/api/tokens', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $name = $_POST['name'] ?? 'API Token'; + $expiresIn = isset($_POST['expires_in']) ? (int)$_POST['expires_in'] : 2592000; // 30 days default + + try { + $tokenData = JWT::createApiToken($user['id'], $name, $expiresIn); + echo json_encode([ + 'success' => true, + 'token' => $tokenData + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: List user's API tokens +Router::get('/api/tokens', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $stmt = DB::get()->prepare(" + SELECT id, name, token, expires_at, created_at, last_used_at + FROM api_tokens + WHERE user_id = ? AND revoked_at IS NULL + ORDER BY created_at DESC + "); + $stmt->execute([$user['id']]); + $tokens = $stmt->fetchAll(); + + // Don't expose full token in list + foreach ($tokens as &$token) { + $token['token'] = substr($token['token'], 0, 10) . '...'; + } + + echo json_encode(['tokens' => $tokens]); +}); + +// API: Revoke API token +Router::delete('/api/tokens/{id}', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + try { + JWT::revokeApiToken($params['id'], $user['id']); + echo json_encode(['success' => true]); + } catch (Exception $e) { + http_response_code(404); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: List servers +Router::get('/api/servers', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $servers = VpnServer::listByUser($user['id']); + echo json_encode(['servers' => $servers]); +}); + +// API: Create server +Router::post('/api/servers/create', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $input = json_decode(file_get_contents('php://input'), true); + + $name = trim($input['name'] ?? ''); + $host = trim($input['host'] ?? ''); + $port = (int)($input['port'] ?? 22); + $username = trim($input['username'] ?? 'root'); + $password = $input['password'] ?? ''; + + if (empty($name) || empty($host) || empty($password)) { + http_response_code(400); + echo json_encode(['error' => 'Missing required fields: name, host, password']); + return; + } + + try { + $serverId = VpnServer::create([ + 'user_id' => $user['id'], + 'name' => $name, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + ]); + + http_response_code(201); + echo json_encode([ + 'success' => true, + 'server_id' => $serverId, + 'message' => 'Server created successfully' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Delete server +Router::delete('/api/servers/{id}/delete', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership or admin + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $server->delete(); + echo json_encode([ + 'success' => true, + 'message' => 'Server deleted successfully' + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: List clients +Router::get('/api/clients', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $clients = VpnClient::listByUser($user['id']); + echo json_encode(['clients' => $clients]); +}); + +// API: Get client details with stats +Router::get('/api/clients/{id}/details', 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']) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + // Sync stats before returning + $client->syncStats(); + + // Reload data + $client = new VpnClient($clientId); + $clientData = $client->getData(); + $stats = $client->getFormattedStats(); + + echo json_encode([ + 'success' => true, + 'client' => [ + 'id' => $clientData['id'], + 'name' => $clientData['name'], + 'server_id' => $clientData['server_id'], + 'client_ip' => $clientData['client_ip'], + 'status' => $clientData['status'], + 'created_at' => $clientData['created_at'], + 'stats' => $stats, + 'bytes_sent' => $clientData['bytes_sent'], + 'bytes_received' => $clientData['bytes_received'], + 'last_handshake' => $clientData['last_handshake'], + ] + ]); + } catch (Exception $e) { + http_response_code(404); + echo json_encode(['error' => 'Client not found']); + } +}); + +// API: Revoke client +Router::post('/api/clients/{id}/revoke', 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']) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + if ($client->revoke()) { + echo json_encode(['success' => true, 'message' => 'Client revoked']); + } else { + http_response_code(500); + echo json_encode(['error' => 'Failed to revoke client']); + } + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Restore client +Router::post('/api/clients/{id}/restore', 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']) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + if ($client->restore()) { + echo json_encode(['success' => true, 'message' => 'Client restored']); + } else { + http_response_code(500); + echo json_encode(['error' => 'Failed to restore client']); + } + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Get server clients +Router::get('/api/servers/{id}/clients', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + // Sync all stats first + VpnClient::syncAllStatsForServer($serverId); + + $clients = VpnClient::listByServer($serverId); + $clientsData = []; + + foreach ($clients as $clientData) { + $client = new VpnClient($clientData['id']); + $stats = $client->getFormattedStats(); + + $clientsData[] = [ + 'id' => $clientData['id'], + 'name' => $clientData['name'], + 'client_ip' => $clientData['client_ip'], + 'status' => $clientData['status'], + 'created_at' => $clientData['created_at'], + 'stats' => $stats, + 'bytes_sent' => $clientData['bytes_sent'], + 'bytes_received' => $clientData['bytes_received'], + 'last_handshake' => $clientData['last_handshake'], + ]; + } + + echo json_encode(['success' => true, 'clients' => $clientsData]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Create client +Router::post('/api/clients/create', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + + $serverId = (int)($data['server_id'] ?? 0); + $name = trim($data['name'] ?? ''); + + if ($serverId <= 0 || empty($name)) { + http_response_code(400); + echo json_encode(['error' => 'server_id and name are required']); + return; + } + + try { + $clientId = VpnClient::create($serverId, $user['id'], $name); + + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + echo json_encode(['client' => $clientData]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +/** + * SETTINGS ROUTES + */ + +// Settings page +Router::get('/settings', function () { + requireAuth(); + + require_once __DIR__ . '/../controllers/SettingsController.php'; + $controller = new SettingsController(); + $controller->index(); +}); + +// Save API key +Router::post('/settings/api-key', function () { + requireAdmin(); + + require_once __DIR__ . '/../controllers/SettingsController.php'; + $controller = new SettingsController(); + $controller->saveApiKey(); +}); + +// Change password +Router::post('/settings/change-password', function () { + requireAuth(); + + require_once __DIR__ . '/../controllers/SettingsController.php'; + $controller = new SettingsController(); + $controller->changePassword(); +}); + +// Add user +Router::post('/settings/add-user', function () { + requireAdmin(); + + require_once __DIR__ . '/../controllers/SettingsController.php'; + $controller = new SettingsController(); + $controller->addUser(); +}); + +// Delete user +Router::post('/settings/delete-user/{id}', function ($params) { + requireAdmin(); + + require_once __DIR__ . '/../controllers/SettingsController.php'; + $controller = new SettingsController(); + $controller->deleteUser($params['id']); +}); + +/** + * LANGUAGE ROUTES + */ + +// Change language +Router::post('/language/change', function () { + $lang = $_POST['language'] ?? ''; + + if (Translator::setLanguage($lang)) { + $_SESSION['success'] = 'Language changed successfully'; + } else { + $_SESSION['error'] = 'Invalid language'; + } + + $redirect = $_POST['redirect'] ?? '/dashboard'; + redirect($redirect); +}); + +Router::get('/language/change', function () { + redirect('/dashboard'); +}); + +// API: Get translation statistics +Router::get('/api/translations/stats', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $stats = Translator::getStatistics(); + echo json_encode(['stats' => $stats]); +}); + +// API: Auto-translate missing keys +Router::post('/api/translations/auto-translate', function () { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + + $targetLang = $data['language'] ?? ''; + + if (empty($targetLang)) { + http_response_code(400); + echo json_encode(['error' => 'Language is required']); + return; + } + + try { + $stats = Translator::translateMissingKeys($targetLang); + echo json_encode([ + 'success' => true, + 'stats' => $stats + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Export translations +Router::get('/api/translations/export/{lang}', function ($params) { + header('Content-Type: application/json'); + + $user = JWT::requireAuth(); + if (!$user) return; + + $lang = $params['lang']; + + try { + $json = Translator::exportToJson($lang); + header('Content-Disposition: attachment; filename="translations_' . $lang . '.json"'); + echo $json; + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// Dispatch router +Router::dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); diff --git a/templates/clients/view.twig b/templates/clients/view.twig new file mode 100644 index 0000000..5ceb28a --- /dev/null +++ b/templates/clients/view.twig @@ -0,0 +1,101 @@ +{% extends "layout.twig" %} +{% block title %}{{ client.name }}{% endblock %} +{% block content %} +
+

{{ client.name }}

+ +
+
+

Client Configuration

+
+
IP Address
{{ client.client_ip }}
+
Status
+
+ {% if client.status == 'active' %} + Active + {% else %} + Disabled + {% endif %} +
+
+
Created
{{ client.created_at }}
+
+
+ + Download Config + + {% if client.status == 'active' %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+
+ +
+
+

Traffic Statistics

+ +
+
+
Uploaded
{{ client.bytes_sent|default(0)|number_format }} B
+
Downloaded
{{ client.bytes_received|default(0)|number_format }} B
+
Total
{{ (client.bytes_sent|default(0) + client.bytes_received|default(0))|number_format }} B
+
Last Handshake
+
+ {% if client.last_handshake %} + {{ client.last_handshake }} + {% else %} + Never connected + {% endif %} +
+
+
+
+
+ + {% if client.qr_code %} +
+

QR Code

+ QR Code +

Scan with Amnezia VPN app

+
+ {% endif %} +
+ + +{% endblock %} diff --git a/templates/dashboard.twig b/templates/dashboard.twig new file mode 100644 index 0000000..2e33e74 --- /dev/null +++ b/templates/dashboard.twig @@ -0,0 +1,134 @@ +{% extends "layout.twig" %} + +{% block title %}{{ t('dashboard.title') }} - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

+ + {{ t('dashboard.title') }} +

+

{{ t('dashboard.welcome') }}

+
+ + +
+
+
+
+ +
+
+

{{ t('dashboard.total_servers') }}

+

{{ servers|length }}

+
+
+
+ +
+
+
+ +
+
+

{{ t('dashboard.total_clients') }}

+

{{ clients|length }}

+
+
+
+ +
+
+
+ +
+
+

{{ t('dashboard.active_clients') }}

+

+ {{ servers|filter(s => s.status == 'active')|length }} +

+
+
+
+
+ + +
+

+ + {{ t('dashboard.quick_actions') }} +

+ +
+ + + {% if servers|length > 0 %} +
+
+

+ + {{ t('dashboard.recent_servers') }} +

+
+ + + + + + + + + + + {% for server in servers|slice(0, 5) %} + + + + + + + {% endfor %} + +
{{ t('servers.name') }}{{ t('servers.host') }}{{ t('servers.status') }}{{ t('servers.actions') }}
+ {{ server.name }} + + {{ server.host }} + + {% if server.status == 'active' %} + + {{ t('status.active') }} + + {% elseif server.status == 'deploying' %} + + {{ t('status.deploying') }} + + {% else %} + + {{ server.status|capitalize }} + + {% endif %} + + + {{ t('servers.view') }} + +
+
+ {% else %} +
+ +

{{ t('dashboard.no_servers') }}

+

{{ t('dashboard.get_started') }}

+ + + {{ t('dashboard.add_first_server') }} + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/layout.twig b/templates/layout.twig new file mode 100644 index 0000000..0a44255 --- /dev/null +++ b/templates/layout.twig @@ -0,0 +1,176 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + + + {% if user %} + + + {% endif %} + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

+ {{ app_name }} © 2025 | Open Source VPN Management Panel +

+
+
+ + + + {% block scripts %}{% endblock %} + + diff --git a/templates/login.twig b/templates/login.twig new file mode 100644 index 0000000..d33dd5c --- /dev/null +++ b/templates/login.twig @@ -0,0 +1,71 @@ +{% extends "layout.twig" %} + +{% block title %}Login - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+ +
+

+ {{ app_name }} +

+

+ Sign in to manage your VPN servers +

+
+ +
+ {% if error %} + + {% endif %} + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ +
+

Default credentials: admin@amnez.ia / admin123

+
+
+
+{% endblock %} diff --git a/templates/register.twig b/templates/register.twig new file mode 100644 index 0000000..a903cf7 --- /dev/null +++ b/templates/register.twig @@ -0,0 +1,57 @@ +{% extends "layout.twig" %} + +{% block title %}Register - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+ +
+

+ Create Account +

+
+ +
+ {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +

Must be at least 6 characters

+
+ + + + +
+
+
+
+{% endblock %} diff --git a/templates/servers/create.twig b/templates/servers/create.twig new file mode 100644 index 0000000..90b26c0 --- /dev/null +++ b/templates/servers/create.twig @@ -0,0 +1,16 @@ +{% extends "layout.twig" %} +{% block title %}Add Server{% endblock %} +{% block content %} +
+

Add New Server

+ {% if error %}
{{ error }}
{% endif %} +
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/templates/servers/deploy.twig b/templates/servers/deploy.twig new file mode 100644 index 0000000..7a5bc15 --- /dev/null +++ b/templates/servers/deploy.twig @@ -0,0 +1,57 @@ +{% extends "layout.twig" %} +{% block title %}Deploy {{ server.name }}{% endblock %} +{% block content %} +
+

Deploying: {{ server.name }}

+
+
Ready to deploy...
+
+ +
+ +{% endblock %} diff --git a/templates/servers/index.twig b/templates/servers/index.twig new file mode 100644 index 0000000..1c3c5a3 --- /dev/null +++ b/templates/servers/index.twig @@ -0,0 +1,66 @@ +{% extends "layout.twig" %} +{% block title %}{{ t('servers.title') }}{% endblock %} +{% block content %} +
+
+

{{ t('servers.title') }}

+ {{ t('servers.add') }} +
+ + {% if session.success_message %} +
+

{{ session.success_message }}

+
+ {% endif %} + + {% if session.error_message %} +
+

{{ session.error_message }}

+
+ {% endif %} + + {% if servers|length > 0 %} +
+ + + + + + + + + + + {% for server in servers %} + + + + + + + {% endfor %} + +
{{ t('servers.name') }}{{ t('servers.host') }}{{ t('servers.status') }}{{ t('servers.actions') }}
{{ server.name }}{{ server.host }} + + {{ server.status }} + + + + {{ t('servers.view') }} + +
+ +
+
+
+ {% else %} +
+

{{ t('dashboard.no_servers') }}

+ {{ t('dashboard.add_first_server') }} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/servers/view.twig b/templates/servers/view.twig new file mode 100644 index 0000000..9df5415 --- /dev/null +++ b/templates/servers/view.twig @@ -0,0 +1,134 @@ +{% extends "layout.twig" %} +{% block title %}{{ server.name }}{% endblock %} +{% block content %} +
+

{{ server.name }}

{{ server.host }}

+
+
+

Server Info

+
+
Status
{{ server.status }}
+
VPN Port
{{ server.vpn_port }}
+
Subnet
{{ server.vpn_subnet }}
+
+
+
+

Create Client

+
+ + +
+
+
+
+
+

Clients ({{ clients|length }})

+ +
+ {% if clients|length > 0 %} + + + + + + + + + + + + + {% for client in clients %} + + + + + + + + + {% endfor %} + +
NameIPStatusTrafficLast SeenActions
{{ client.name }}{{ client.client_ip }} + {% if client.status == 'active' %} + Active + {% else %} + Disabled + {% endif %} + +
+ ↑ {{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB +
+
+ ↓ {{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB +
+
+ {% if client.last_handshake %} + {{ client.last_handshake }} + {% else %} + Never + {% endif %} + + View + {% if client.status == 'active' %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ +
+
+ {% else %} +
No clients yet
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/settings.twig b/templates/settings.twig new file mode 100644 index 0000000..cabe711 --- /dev/null +++ b/templates/settings.twig @@ -0,0 +1,338 @@ +{% extends "layout.twig" %} + +{% block title %}{{ t('menu.settings') }} - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

{{ t('menu.settings') }}

+

{{ t('settings.description') }}

+
+ + {% if success %} +
+
+ +

{{ success }}

+
+
+ {% endif %} + + {% if error %} +
+
+ +

{{ error }}

+
+
+ {% endif %} + + +
+ +
+ + +
+
+
+

+ Change Password +

+
+
+
+
+
+ + +
+
+ + +

Minimum 6 characters

+
+
+ + +
+ +
+
+
+
+
+ + + + + + + + + {% if user.role == 'admin' %} + + {% endif %} +
+ + +{% endblock %} diff --git a/templates/settings_old.twig b/templates/settings_old.twig new file mode 100644 index 0000000..288e0b9 --- /dev/null +++ b/templates/settings_old.twig @@ -0,0 +1,199 @@ +{% extends "layout.twig" %} + +{% block title %}{{ t('menu.settings') }} - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

{{ t('menu.settings') }}

+

{{ t('settings.description') }}

+
+ + {% if success %} +
+
+
+ +
+
+

{{ success }}

+
+
+
+ {% endif %} + + {% if error %} +
+
+
+ +
+
+

{{ error }}

+
+
+
+ {% endif %} + +
+ +
+

+ + {{ t('settings.api_keys') }} +

+

{{ t('settings.api_keys_desc') }}

+
+ +
+ +
+
+ +
+ + +
+

+ + {{ t('settings.get_key_at') }} openrouter.ai/keys +

+
+ + + +
+ +
+
+
+ + +
+

+ + {{ t('settings.translation_status') }} +

+ +
+ + + + + + + + + + {% for stat in translation_stats %} + + + + + + {% endfor %} + +
+ {{ t('settings.language') }} + + {{ t('settings.progress') }} + + {{ t('settings.actions') }} +
+
+ {{ getFlag(stat.code) }} +
+
{{ stat.name }}
+
({{ stat.code }})
+
+
+
+ {% set percent = (stat.translated_count / stat.total_count * 100)|round %} +
+
+
+
+ {{ percent }}% +
+
+ {{ stat.translated_count }} / {{ stat.total_count }} {{ t('settings.keys') }} +
+
+ {% if stat.code != 'en' and stat.translated_count < stat.total_count %} + + {% endif %} +
+
+
+
+
+ + +{% endblock %} diff --git a/test_qr.php b/test_qr.php new file mode 100644 index 0000000..19db128 --- /dev/null +++ b/test_qr.php @@ -0,0 +1,59 @@ +getMessage() . "\n"; + echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + exit(1); +}