commit 311cb4e4fa6d823b6ae8e26af2f26defcced6455 Author: Urvish Patel Date: Tue Jul 8 20:21:50 2025 +0200 initialize Project diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..20ebb06 Binary files /dev/null and b/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000..fadd49a Binary files /dev/null and b/backend/.DS_Store differ diff --git a/backend/app/README.md b/backend/app/README.md new file mode 100644 index 0000000..5d8edb7 --- /dev/null +++ b/backend/app/README.md @@ -0,0 +1,156 @@ +# LXD Proxy API (Slim Framework) + +This is a PHP-based API using the **Slim 4 Framework** that dynamically manages and proxies traffic to LXD containers based on subdomain mappings. + +--- + +## ๐Ÿงฉ Features + +- Automatically creates and starts LXD containers per subdomain +- CAPTCHA validation for provisioning +- Proxies requests to containerized environments +- Persists domain-to-container mapping +- Waits for container service to be ready before forwarding +- Logs last access per container + +--- + +## ๐Ÿ“ Project Structure + +backend/ +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ Controllers/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ProxyController.php +โ”‚ โ”‚ โ”œโ”€โ”€ Services/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ LxdService.php +โ”‚ โ”‚ โ””โ”€โ”€ Utils/ +โ”‚ โ”‚ โ””โ”€โ”€ SubdomainHelper.php +โ”‚ โ”œโ”€โ”€ vendor/ # Composer dependencies +โ”‚ โ”œโ”€โ”€ public/ +โ”‚ โ”‚ โ””โ”€โ”€ last-access-logs/ +โ”‚ โ”œโ”€โ”€ config.json # Domain-to-container mappings +โ”‚ โ””โ”€โ”€ index.php # Slim entry point +โ”œโ”€โ”€ composer.json +โ””โ”€โ”€ README.md + + +--- + + +--- + +## โš™๏ธ Requirements + +- PHP 8.1+ +- LXD installed and configured +- PHP-FPM + NGINX +- Composer +- `guzzlehttp/guzzle` +- `slim/slim` +- `slim/psr7` + +--- + +## ๐Ÿš€ Setup Instructions + +1. **put the folder in /var/www/html** + +2. **Install dependencies** + + composer install + + +3. **Ensure PHP and NGINX are configured** + +NGINX config example: + +server { + listen 80; + server_name *.lxdapp.local; + + root /var/www/html/lxd-app/backend/public; + index index.php; + + location / { + try_files $uri /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_read_timeout 300; + } +} + + +4. **Map domain in /etc/hosts** + + 127.0.0.1 customer1.lxdapp.local + +5. **Make sure LXD is working** + + lxc list + +6. **๐Ÿ” CAPTCHA Protection** + + The /api/v1/proxy POST endpoint expects a field panswer with the correct CAPTCHA. + + You can configure PCaptcha class for custom logic. + + +7. **๐Ÿงช API Usage** + + POST /api/v1/proxy + + **Request Body:** + + { + "source": "login", + "panswer": "abc123" + } + + **Headers:** + + Origin: http://customer1.lxdapp.local + + **Response:** + + { + "status": "success", + "ip": "10.210.189.24" + } + + + + + +**๐Ÿง  Notes** + + Container names are auto-generated using the subdomain prefix. + + All containers are mapped in config.json. + + If a container does not yet exist, it's created, started, and software is installed. + + The system waits for the container to expose port 80 before proxying. + + +**๐Ÿชต Logs** + + Last access logs for containers are saved in: + public/last-access-logs/container-*.txt + + NGINX error logs (for debugging): + tail -f /var/log/nginx/error.log + + +**๐Ÿ‘จโ€๐Ÿ’ป Development** + + Codebase follows PSR-4 autoloading (App\ namespace). + + To add new functionality, work within app/src/Controllers or Services. + + diff --git a/backend/app/composer.json b/backend/app/composer.json new file mode 100644 index 0000000..d0baf24 --- /dev/null +++ b/backend/app/composer.json @@ -0,0 +1,14 @@ +{ + "require": { + "slim/slim": "4.*", + "slim/psr7": "^1.7", + "php-di/php-di": "^7.0", + "guzzlehttp/guzzle": "^7.9", + "nyholm/psr7": "^1.8" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} diff --git a/backend/app/config.json b/backend/app/config.json new file mode 100644 index 0000000..a8b3f8f --- /dev/null +++ b/backend/app/config.json @@ -0,0 +1,3 @@ +{ + "customer1.lxdapp.local": "container-customer1" +} \ No newline at end of file diff --git a/backend/app/public/.htaccess b/backend/app/public/.htaccess new file mode 100644 index 0000000..51c6bee --- /dev/null +++ b/backend/app/public/.htaccess @@ -0,0 +1,3 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ index.php [QSA,L] diff --git a/backend/app/public/index.php b/backend/app/public/index.php new file mode 100644 index 0000000..41e12a8 --- /dev/null +++ b/backend/app/public/index.php @@ -0,0 +1,67 @@ + 0, + 'path' => '/', + 'domain' => $domain, + 'secure' => false, // set true if using HTTPS + 'httponly' => true, + 'samesite' => 'Lax', +]); + +if (session_status() === PHP_SESSION_NONE) { + session_start(); +} +use DI\ContainerBuilder; +use Slim\Factory\AppFactory; + +require __DIR__ . '/../vendor/autoload.php'; + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +// Build Container using PHP-DI +$containerBuilder = new ContainerBuilder(); +$containerBuilder->useAutowiring(true); // Enable autowiring globally + + +// Add settings +$settings = require __DIR__ . '/../src/Settings/settings.php'; +$settings($containerBuilder); + +// Add dependencies +$dependencies = require __DIR__ . '/../src/Dependencies/dependencies.php'; +$dependencies($containerBuilder); + +// Build the container +$container = $containerBuilder->build(); + +// Set container to AppFactory +AppFactory::setContainer($container); + +// Create App +$app = AppFactory::create(); + +$app->add(function ($request, $handler) { + $response = $handler->handle($request); + + // $origin = $request->getHeaderLine('Origin') ?: '*'; + $origin = $_SERVER['HTTP_ORIGIN'] ?? '*'; + return $response + ->withHeader('Access-Control-Allow-Origin', $origin) + ->withHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization') + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + ->withHeader('Access-Control-Allow-Credentials', 'true'); +}); + + +// Register middleware +(require __DIR__ . '/../src/Bootstrap/middleware.php')($app); + +// Register routes +(require __DIR__ . '/../src/Bootstrap/routes.php')($app); + +// Run app +$app->run(); diff --git a/backend/app/public/last-access-logs/container-mitul.txt b/backend/app/public/last-access-logs/container-mitul.txt new file mode 100644 index 0000000..827dbe7 --- /dev/null +++ b/backend/app/public/last-access-logs/container-mitul.txt @@ -0,0 +1,9 @@ +2025-07-08 13:07:05 : http://10.110.90.30:80 +2025-07-08 13:29:10 : http://10.110.90.30:80 +2025-07-08 14:43:45 : http://10.110.90.144:80 +2025-07-08 14:50:28 : http://10.110.90.89:80 +2025-07-08 14:53:41 : http://10.110.90.89:80 +2025-07-08 15:07:07 : http://10.110.90.95:80 +2025-07-08 15:12:49 : http://10.110.90.95:80 +2025-07-08 15:23:59 : http://10.110.90.147:80 +2025-07-08 15:24:26 : http://10.110.90.147:80 diff --git a/backend/app/src/Bootstrap/middleware.php b/backend/app/src/Bootstrap/middleware.php new file mode 100644 index 0000000..7602cac --- /dev/null +++ b/backend/app/src/Bootstrap/middleware.php @@ -0,0 +1,40 @@ +addBodyParsingMiddleware(); + + // Add error middleware + $container = $app->getContainer(); + $settings = $container->get('settings'); + + $errorMiddleware = new ErrorMiddleware( + $app->getCallableResolver(), + $app->getResponseFactory(), + $settings['displayErrorDetails'], + true, + true + ); + + // Optional custom 404 handler + $errorMiddleware->setErrorHandler( + Slim\Exception\HttpNotFoundException::class, + function ( + Psr\Http\Message\ServerRequestInterface $request, + Throwable $exception, + bool $displayErrorDetails, + bool $logErrors, + bool $logErrorDetails + ) use ($app) { + $response = $app->getResponseFactory()->createResponse(); + $response->getBody()->write('404 Not Found โ€” Custom Page'); + return $response->withStatus(404); + } + ); + + $app->add($errorMiddleware); +}; diff --git a/backend/app/src/Bootstrap/routes.php b/backend/app/src/Bootstrap/routes.php new file mode 100644 index 0000000..b6c24a5 --- /dev/null +++ b/backend/app/src/Bootstrap/routes.php @@ -0,0 +1,11 @@ +width = 200; + $captcha->hight = 50; + $captcha->leng = 6; + + try { + $imageData = $captcha->get_captcha(); + } catch (\Throwable $e) { + $response->getBody()->write("Captcha error: " . $e->getMessage()); + return $response->withStatus(500)->withHeader('Content-Type', 'text/plain'); + } + + $response->getBody()->write($imageData); + return $response->withHeader('Content-Type', 'image/png'); + } +} diff --git a/backend/app/src/Controllers/ProxyController.php b/backend/app/src/Controllers/ProxyController.php new file mode 100644 index 0000000..eb6edf1 --- /dev/null +++ b/backend/app/src/Controllers/ProxyController.php @@ -0,0 +1,173 @@ +getHeaderLine('Origin'); + $domain = parse_url($origin, PHP_URL_HOST); // e.g. customer1.lxdapp.local + $params = (array)$request->getParsedBody(); + + $configPath = __DIR__ . '/../../config.json'; + $config = file_exists($configPath) ? json_decode(file_get_contents($configPath), true) : []; + $name = $config[$domain] ?? null; + + $lxd = new LxdService(); + // print_r($params); + // CASE 1: From Login Page โ€“ Create if not mapped + if (isset($params['source']) && $params['source'] === 'login') { + + $captcha = new PCaptcha(); + + if (!$captcha->validate_captcha($params['panswer'])) { + return $this->json($response, ['status' => 'error', 'error' => 'invalid_captcha']); + } + + if (!$name) { + $subdomain = SubdomainHelper::getSubdomain($domain, $mainDomain); + $name = $this->generateContainerName($subdomain); // or just use subdomain + $config[$domain] = $name; + file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT)); + } + + if (!$lxd->containerExists($name)) { + $lxd->createContainerAndWait($name); + sleep(5); + //$lxd->startContainer($name); + $lxd->installPackages($name); + sleep(5); + } + + $ip = $lxd->getContainerIP($name); + + if (!$ip) { + return $this->json($response, [ + 'status' => 'error', + 'message' => "Failed to get container IP for '$name'" + ], 500); + } + + // if (!$lxd->waitForPort($ip, 80, 30)) { + // return $this->json($response, ['status' => 'error', 'message' => 'Container not ready'], 500); + // } + + return $this->json($response, ['status' => 'success', 'ip' => $ip]); + } + + // CASE 2: Not from login and no mapping + if (!$name) { + return $this->json($response, ['status' => 'not-found']); + } + + // CASE 3: Check if container exists in LXD + $containerInfo = $lxd->getContainerState($name); + if (!$containerInfo) { + return $this->json($response, ['status' => 'not-found']); + } + + if ($containerInfo['metadata']['status'] !== 'Running') { + $lxd->startContainer($name); + sleep(5); + } + + + $ip = $lxd->getContainerIP($name); + // if (!$ip) { + // $ip = $lxd->getContainerIP($name); + // } + + if (!$lxd->waitForPort($ip, 80, 30)) { + return $this->json($response, ['status' => 'error', 'message' => 'Service not available'], 500); + } + + return $this->proxyToContainer($request, $response, $ip, $name); + } + + private function mapDomainToContainer(string $domain): ?string + { + $configPath = __DIR__ . '/../../config.json'; + + if (!file_exists($configPath)) { + return null; + } + + $config = json_decode(file_get_contents($configPath), true); + + return $config[$domain] ?? null; + } + + + protected function json($response, array $data, int $status = 200) + { + $payload = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $response->getBody()->write($payload); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withHeader('Access-Control-Allow-Origin', '*') + ->withStatus($status); + } + + public function generateContainerName(string $subdomain): string + { + // Convert to lowercase, remove unsafe characters + $sanitized = preg_replace('/[^a-z0-9\-]/', '-', strtolower($subdomain)); + + // Optionally, ensure it's prefixed/suffixed for uniqueness + return "container-{$sanitized}"; + } + + private function proxyToContainer(Request $request, Response $response, string $ip, string $name): Response + { + $target = $ip ? "http://$ip:80" : "http://127.0.0.1:3000"; + + $client = new Client([ + 'http_errors' => false, + 'timeout' => 5, + ]); + + // Just make a GET request to the base URL + $forwarded = $client->request('GET', $target); + + $this->writeLastAccessLog($name, $target); + + // Return the body and status + $response->getBody()->write((string) $forwarded->getBody()); + return $response + ->withHeader('Content-Type', 'text/html') // or json if API + ->withHeader('Access-Control-Allow-Origin', '*') + ->withStatus($forwarded->getStatusCode()); + + } + + protected function writeLastAccessLog(string $name, string $uri): void { + // Dynamically resolve the log directory relative to the current file + $logDir = realpath(__DIR__ . '/../../public/last-access-logs'); + + // If the resolved path doesn't exist (e.g., public dir was missing), create it + if (!$logDir) { + $logDir = __DIR__ . '/../../public/last-access-logs'; + if (!file_exists($logDir)) { + mkdir($logDir, 0777, true); + } + } + + $logLine = date("Y-m-d H:i:s") . " : " . $uri . "\n"; + file_put_contents($logDir . '/' . $name . '.txt', $logLine, FILE_APPEND); + } + + +} \ No newline at end of file diff --git a/backend/app/src/Dependencies/dependencies.php b/backend/app/src/Dependencies/dependencies.php new file mode 100644 index 0000000..075a3b7 --- /dev/null +++ b/backend/app/src/Dependencies/dependencies.php @@ -0,0 +1,12 @@ +addDefinitions([ + \App\Services\LxdService::class => autowire() + ]); +}; diff --git a/backend/app/src/Managers/LXDProxyManager.php b/backend/app/src/Managers/LXDProxyManager.php new file mode 100644 index 0000000..e087b11 --- /dev/null +++ b/backend/app/src/Managers/LXDProxyManager.php @@ -0,0 +1,143 @@ +lxdService = $lxdService; + } + + /** + * Handle request to container: return IP or null if fallback needed. + */ + public function handleRequest(string $name, $domain) + { + try { + $container = $this->lxdService->containerExists($name); + + if (!$container) { + $this->lxdService->createContainer($name); + sleep(10); + + $this->lxdService->startContainer($name); + $maxRetries = 20; + $delay = 2; + + for ($i = 0; $i < $maxRetries; $i++) { + $status = $this->lxdService->getStatus($name); + if ($status['metadata']['status'] === 'Running') { + break; + } + sleep($delay); + } + + $this->lxdService->installPackages($name); + sleep(5); + + $ipv4 = $this->lxdService->getContainerIP($name); + $maxRetries = 20; + $waitSeconds = 3; + $ready = false; + for ($i = 0; $i < $maxRetries; $i++) { + // Try to connect to port 80 on container IP (you can use fsockopen, curl, or similar) + $connection = @fsockopen($ipv4, 80, $errno, $errstr, 2); + if ($connection) { + fclose($connection); + $ready = true; + break; + } + sleep($waitSeconds); + } + + $hostConfigPath = __DIR__ . '/../../config.json'; + if (file_exists($hostConfigPath)) { + $hostConfigData = json_decode(file_get_contents($hostConfigPath), true); + } else { + // If the file doesn't exist, initialize an empty array + $hostConfigData = []; + } + + // Step 3: Append the new container entry to the JSON data + $hostConfigData[$domain] = $name; + + // Step 4: Write the updated data back to the host-config.json file + file_put_contents($hostConfigPath, json_encode($hostConfigData, JSON_PRETTY_PRINT)); + + + @mkdir('/var/www/last-access', 0777, true); + @touch("/var/www/last-access/$name.txt"); + + return [ + 'status' => 'success', + 'ip' => $ipv4, + ]; + } + + if ($container['metadata']['status'] === 'Running') { + $ipv4 = $this->getIPv4FromMetadata($container['metadata']); + + return [ + 'status' => 'success', + 'ip' => $ipv4, + ]; + } + + // Start the container and wait for IP + $this->lxdService->startContainer($name); + + $maxRetries = 30; + $delay = 2; + $ipv4 = null; + + for ($i = 0; $i < $maxRetries; $i++) { + $status = $this->lxdService->getStatus($name); + $ipv4 = $this->getIPv4FromMetadata($status['metadata']); + + if (!empty($ipv4)) { + break; + } + + sleep($delay); + } + + if ($ipv4) { + return [ + 'status' => 'success', + 'ip' => $ipv4, + ]; + } + + return [ + 'status' => 'failed', + 'message' => 'Container did not acquire an IP in time.', + ]; + } catch (\Exception $e) { + return [ + 'status' => 'failed', + 'error' => $e->getMessage(), + ]; + } + + } + + public function getIPv4FromMetadata(array $metadata): ?string + { + if ( + isset($metadata['network']['eth0']['addresses']) && + is_array($metadata['network']['eth0']['addresses']) + ) { + foreach ($metadata['network']['eth0']['addresses'] as $addr) { + if (isset($addr['family']) && $addr['family'] === 'inet' && isset($addr['address'])) { + return $addr['address']; + } + } + } + return null; + } + +} diff --git a/backend/app/src/Routes/v1/proxy.php b/backend/app/src/Routes/v1/proxy.php new file mode 100644 index 0000000..a1ee1fe --- /dev/null +++ b/backend/app/src/Routes/v1/proxy.php @@ -0,0 +1,11 @@ +group('/api/v1', function ($group) { + $group->any('/proxy', [ProxyController::class, 'forward']); + }); +}; diff --git a/backend/app/src/Routes/web.php b/backend/app/src/Routes/web.php new file mode 100644 index 0000000..5946310 --- /dev/null +++ b/backend/app/src/Routes/web.php @@ -0,0 +1,19 @@ +get('/', function (ServerRequestInterface $request, ResponseInterface $response) { + $response->getBody()->write('Welcome to the LXD App!'); + return $response; + }); + + // Captcha route + $app->get('/api/captcha', [CaptchaController::class, 'get']); + +}; diff --git a/backend/app/src/Services/LxdService.php b/backend/app/src/Services/LxdService.php new file mode 100644 index 0000000..bbe207a --- /dev/null +++ b/backend/app/src/Services/LxdService.php @@ -0,0 +1,262 @@ +baseUrl = "http://unix.socket"; + $this->baseUrl = "https://localhost:8443"; + $this->socketPath = "/var/snap/lxd/common/lxd/unix.socket"; + } + + private function request(string $method, string $endpoint, array $body = []): array { + $ch = curl_init("{$this->baseUrl}{$endpoint}"); + // Paths to your client cert and key + $clientCert = '/etc/ssl/lxdapp/client.crt'; + $clientKey = '/etc/ssl/lxdapp/client.key'; + + //curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $this->socketPath); + + // Specify your client certificate and key for TLS authentication + curl_setopt($ch, CURLOPT_SSLCERT, $clientCert); + curl_setopt($ch, CURLOPT_SSLKEY, $clientKey); + // For testing only: disable peer verification (use carefully in production!) + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + + // curl_setopt($ch, CURLOPT_URL, "{$this->baseUrl}{$endpoint}"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + + if (!empty($body)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + // Check if curl_exec failed + if (curl_errno($ch)) { + throw new Exception("Curl error: " . curl_error($ch)); + } + + curl_close($ch); + + if (!$response) { + throw new Exception("LXD API error or not reachable."); + } + + $json = json_decode($response, true); + if ($httpCode >= 400) { + throw new Exception("LXD API Error: " . ($json['error'] ?? 'Unknown')); + } + + return $json; + } + + public function getStatus(string $name): ?array + { + try { + return $this->request('GET', "/1.0/instances/$name/state"); + } catch (\Throwable $e) { + return null; + } + } + + public function getContainer(string $name) { + try { + return $this->request('GET', "/1.0/instances/$name"); + } catch (\Throwable $e) { + return null; + } + } + + public function getContainerState(string $name) { + try { + return $this->request('GET', "/1.0/instances/$name/state"); + } catch (\Throwable $e) { + return null; + } + } + + public function containerExists(string $name): bool { + return $this->getContainerState($name) !== null; + } + + public function startContainer(string $name): array { + $startResponse = $this->request('PUT', "/1.0/instances/$name/state", [ + "action" => "start", + "timeout" => 30, + "force" => true + ]); + + if (!isset($startResponse['operation'])) { + throw new \Exception("No operation returned from start request"); + } + + $startOpUrl = $startResponse['operation']; + + // 5. Wait for start operation to complete + do { + sleep(1); + $startOpResp = $this->request('GET', $startOpUrl); + $startStatus = $startOpResp['metadata']['status_code'] ?? 0; + } while ($startStatus < 200); + + if ($startStatus >= 400) { + throw new \Exception("Failed to start container: " . json_encode($startOpResp)); + } + + // 6. Return final container info + $containerResponse = $this->getContainer($name); + return $containerResponse['metadata'] ?? []; + } + + public function stopContainer(string $name): array { + $response = $this->request('PUT', "/1.0/instances/$name/state", [ + "action" => "stop", + "timeout" => 30, + "force" => true + ]); + + return $response; + } + + public function createContainer(string $name, string $fingerprint = "2edfd84b1396"): array { + $response = $this->request('POST', "/1.0/instances", [ + "name" => $name, + "source" => [ + "type" => "image", + "fingerprint" => $fingerprint + ] + ]); + sleep(5); + return $response; + } + + public function installPackages(string $name) { + $log = ''; + + $log .= "=== apt update ===\n"; + $out1 = shell_exec("/snap/bin/lxc exec $name -- bash -c 'apt update 2>&1'"); + $log .= $out1 . "\n\n"; + + $log .= "=== apt install ===\n"; + $out2 = shell_exec("/snap/bin/lxc exec $name -- bash -c 'apt install -y nginx mysql-server 2>&1'"); + $log .= $out2 . "\n\n"; + + file_put_contents('/tmp/lxd_install.log', $log); + + // Wait for nginx service to be active + $nginxActive = false; + for ($i = 0; $i < 10; $i++) { + $status = shell_exec("/snap/bin/lxc exec $name -- systemctl is-active nginx"); + if (trim($status) === 'active') { + $nginxActive = true; + break; + } + sleep(2); // wait 2 seconds before retry + } + + // Wait for mysql service to be active + $mysqlActive = false; + for ($i = 0; $i < 10; $i++) { + $status = shell_exec("/snap/bin/lxc exec $name -- systemctl is-active mysql"); + if (trim($status) === 'active') { + $mysqlActive = true; + break; + } + sleep(2); + } + + if (!$nginxActive || !$mysqlActive) { + file_put_contents('/tmp/lxd_install.log', "Service(s) failed to start: nginx active=$nginxActive, mysql active=$mysqlActive\n", FILE_APPEND); + throw new \Exception("Failed to start nginx or mysql inside container $name"); + } + } + + + + public function getContainerIP($name) + { + $container = $this->getStatus($name); + return $this->getIPv4FromMetadata($container['metadata']); + } + + public function createContainerAndWait(string $name, array $config = []): array { + // 1. Prepare the creation request body + $body = [ + "name" => $name, + "source" => array_merge([ + "type" => "image", + "fingerprint" => "2edfd84b1396" + ]), + ]; + + $body = array_merge($body, $config); + + // 2. Send creation request + $response = $this->request('POST', '/1.0/instances', $body); + + if (!isset($response['operation'])) { + throw new \Exception("No operation returned from create request"); + } + + $operationUrl = $response['operation']; + + // 3. Wait for container creation to finish + do { + sleep(1); + $opResponse = $this->request('GET', $operationUrl); + $statusCode = $opResponse['metadata']['status_code'] ?? 0; + } while ($statusCode < 200); + + if ($statusCode >= 400) { + throw new \Exception("Container creation failed: " . json_encode($opResponse)); + } + + // 4. Start the container + return $this->startContainer($name); + } + + + public function waitForPort(string $ip, int $port = 80, int $timeout = 30): bool + { + $startTime = time(); + + while ((time() - $startTime) < $timeout) { + $connection = @fsockopen($ip, $port, $errno, $errstr, 2); + + if ($connection) { + fclose($connection); + return true; // Port is open + } + + sleep(1); // Wait 1 second before retrying + } + + return false; // Timed out + } + + + // Function to get IPv4 address from metadata + public function getIPv4FromMetadata(array $metadata): ?string + { + if ( + isset($metadata['network']['eth0']['addresses']) && + is_array($metadata['network']['eth0']['addresses']) + ) { + foreach ($metadata['network']['eth0']['addresses'] as $addr) { + if (isset($addr['family']) && $addr['family'] === 'inet' && isset($addr['address'])) { + return $addr['address']; + } + } + } + return null; + } +} \ No newline at end of file diff --git a/backend/app/src/Settings/settings.php b/backend/app/src/Settings/settings.php new file mode 100644 index 0000000..0d7df15 --- /dev/null +++ b/backend/app/src/Settings/settings.php @@ -0,0 +1,11 @@ +addDefinitions([ + 'settings' => [ + 'displayErrorDetails' => true, // turn off in production + ], + ]); +}; diff --git a/backend/app/src/Utils/SubdomainHelper.php b/backend/app/src/Utils/SubdomainHelper.php new file mode 100644 index 0000000..2c0138b --- /dev/null +++ b/backend/app/src/Utils/SubdomainHelper.php @@ -0,0 +1,14 @@ +gen_cpatcha_image(); + } catch (Throwable $e) { + http_response_code(500); + header("Content-Type: text/plain"); + echo "Captcha generation failed: " . $e->getMessage(); + } + } else { + http_response_code(500); + header("Content-Type: text/plain"); + echo "GD library not found"; + } + exit(); + } + + + /** + * Validate captcha data in post fields + * + * @return bool + */ + function validate_captcha($answer) + { + // $answer = trim((string) ($_POST[$this->postfield] ?? '')); + + if((!empty($_SESSION['p_code']) && $answer != '') && ($_SESSION['p_code'] == $answer)) + { + unset($_SESSION['p_code']); + return true; + } + + return false; + } + + + + /** + * Generate a CAPTCHA + * + * @return void + */ + function gen_cpatcha_image() + { + + + if(!isset($this->font)) + { + $this->font = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'arial.ttf'; + } + if($this->background[0] == 0) + { + $this->background[0] = mt_rand(70, 200); + $this->background[1] = 0; + $this->background[2] = mt_rand(70, 200); + } + + //generate a secure random int by using openssl + $hash = bin2hex(openssl_random_pseudo_bytes(16)); + $num = hexdec( substr(sha1($hash) , 0,15) ); + $security_code = substr(intval($num) ,0, $this->leng); //trim it down to $leng + + //store the security code + $_SESSION["p_code"] = $security_code; + + //Set the image width and height + $width = $this->width; + $height = $this->hight; + + $sc = 0; + + //gen scale + if($this->scale <= 0) + { + $sc = $height / $width; + $sc = ($sc < 1) ? 2 : $sc + 2; + } + else + { + $sc = $this->scale; + } + + //gen size + $size = ($this->size <= 0) ? round($height / $sc) : $this->size; + + + //Create the image resource + $image = ImageCreate($width, $height); + + + //grab our paint tools , colors, white, black and gray + $white = ImageColorAllocate($image, $this->text[0], $this->text[1], $this->text[2]); + $black = ImageColorAllocate($image, $this->background[0], $this->background[1], $this->background[2]); + $grey = ImageColorAllocate($image, $this->colortwo[0], $this->colortwo[1], $this->colortwo[2]); + + + //fill the world in black + ImageFill($image, 0, 0, $black); + + $text_count = strlen($security_code); + $text = str_split($security_code); + $step = round($width/3) - $sc * 15; + $y = $size + mt_rand(0, round($height/3) - $sc) ; + foreach($text as $txt) + { + $box = imagettfbbox( $size, 0, $this->font, $txt ); + $x = abs($box[2]-$box[0]); + + imagettftext($image, $size, mt_rand(-$this->angle, 0), $step, $y, $white, $this->font, $txt ); + $step += ($x+5); + } + + if($this->pixlized > 1) + { + imagefilter($image, IMG_FILTER_PIXELATE, $this->pixlized, true); + } + + if($this->grid) + { + $transp = $black; + $this->imagegrid($image, $width, $height, 10, $transp); + } + + if($this->drawcircles) + { + $transp2 = ImageColorAllocate($image, $this->background[0] + 30, $this->background[1], $this->background[2] + 30); + $this->imagecircles($image, $width, $height, 10, $transp2); + } + + if ($this->noise_level > 0) + { + $noise_level = $this->noise_level; + + for ($i = 0; $i < $noise_level; ++$i) + { + $x = mt_rand(2, $width); + $y = mt_rand(2, $height); + $size = 2; + imagefilledarc($image, $x, $y, $size, $size, 0, 360, $white, IMG_ARC_PIE); + } + + + } + + //drow borders + ImageRectangle($image,0,0,$width-1,$height-1,$grey); + + if($this->drawcross) + { + imageline($image, 0, $height/2, $width, $height/2, $grey); + imageline($image, $width/2, 0, $width/2, $height, $grey); + } + + //header file + header("Content-Type: image/png"); + + //Output the newly created image in jpeg format + ImagePng($image); + + //Free up resources + ImageDestroy($image); + + } + + /** + * Draw circles + * @param image $image given image + * @param width $w image's width + * @param height $w image's height + * @param size $w circles size + * @param color $color + * @return void + */ + function imagecircles($image, $w, $h, $s, $color) + { + $x = $w/2; + $y = $h/2; + for($iw=1; $iw<$w; $iw++) + { + imagearc($image, $x, $y, $iw*$s, $iw*$s, 0, 360, $color); + + } + + } + + /** + * Draws a nice looking grid + * @param image $image given image + * @param width $w image's width + * @param height $w image's height + * @param size $w size + * @param color $color + * @return void + */ + function imagegrid($image, $w, $h, $s, $color) + { + for($iw=1; $iw<$w/$s; $iw++){imageline($image, $iw*$s, 0, $iw*$s, $w, $color );} + for($ih=1; $ih<$h/$s; $ih++){imageline($image, 0, $ih*$s, $w, $ih*$s, $color );} + } +} \ No newline at end of file diff --git a/backend/app/src/lib/arial.ttf b/backend/app/src/lib/arial.ttf new file mode 100644 index 0000000..ff0815c Binary files /dev/null and b/backend/app/src/lib/arial.ttf differ diff --git a/backend/app/src/scripts/auto-stop-containers.php b/backend/app/src/scripts/auto-stop-containers.php new file mode 100755 index 0000000..6294602 --- /dev/null +++ b/backend/app/src/scripts/auto-stop-containers.php @@ -0,0 +1,68 @@ +getTimestamp() - $lastAccess->getTimestamp(); + + // Check if the container has been idle for longer than the threshold + if ($interval > $thresholdMinutes * 60) { + echo "$containerName has been idle for over $thresholdMinutes minutes. Stopping...\n"; + + try { + // Check if the container exists and stop it if it does + if ($lxdService->containerExists($containerName)) { + $lxdService->stopContainer($containerName); + echo "Stopped container: $containerName\n"; + } else { + echo "Container $containerName does not exist.\n"; + } + } catch (Throwable $e) { + // Handle any errors that occur while stopping the container + echo "Error stopping $containerName: " . $e->getMessage() . "\n"; + } + } +} + +/** + * Get the last non-empty line from a file. + * + * @param string $filePath Path to the file. + * @return string|null The last line, or null if the file is empty. + */ +function getLastLine(string $filePath): ?string +{ + $lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + return $lines ? end($lines) : null; +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..4a7f73a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..17aea8b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,92 @@ +# LXD Frontend (Nuxt.js) + +This is the **frontend Nuxt.js** application that works with the [LXD Proxy API](../backend/README.md) to dynamically provision LXD containers based on subdomain access and user CAPTCHA validation. + +--- + +## ๐ŸŒ Overview + +- Each user has a subdomain (e.g., `customer1.lxdapp.local`) +- On visiting the subdomain, a login page prompts for CAPTCHA +- Upon successful CAPTCHA, a request is made to the backend to: + - Create or start an LXD container + - Wait until it's ready + - Proxy user to the container + +--- + +## ๐Ÿงฉ Features + +- Subdomain-aware dynamic environment provisioning +- CAPTCHA login screen for triggering container creation +- API integration with backend (Slim PHP) service +- Axios-based POST to `/api/v1/proxy` + +--- + +## ๐Ÿ›  Project Setup + +1. **Install dependencies** + ```bash + npm install + +2. **Run the development server** + ```bash + npm run dev + +3. **Visit the subdomain** + + http://customer1.lxdapp.local:3000 + + โš ๏ธ Make sure this domain is mapped in /etc/hosts and that the backend is running on the correct origin. + + +**๐Ÿ—‚ Project Structure** + +frontend/ +โ”œโ”€โ”€ pages/ +โ”‚ โ””โ”€โ”€ index.vue # Login screen +โ”œโ”€โ”€ plugins/ +โ”‚ โ””โ”€โ”€ axios.js # Axios config (if used) +โ”œโ”€โ”€ nuxt.config.js # App config +โ”œโ”€โ”€ static/ +โ”‚ โ””โ”€โ”€ favicon.ico +โ”œโ”€โ”€ package.json +โ””โ”€โ”€ README.md + + +**๐Ÿ” CAPTCHA Flow** + +User opens subdomain: customer1.lxdapp.local + +Enters the CAPTCHA value + +Form submits: + +POST /api/v1/proxy with JSON payload: + + { + "source": "login", + "panswer": "abc123" + } + +Adds header: + + Origin: http://customer1.lxdapp.local + +**Backend:** + + Validates CAPTCHA + + Creates or starts container + + Responds with container IP proxy + + +**๐Ÿงช Development Notes** + + config.json in backend maps subdomain to LXD container + + This frontend app assumes the backend is on the same subdomain + + If needed, configure proxy in nuxt.config.js or use relative API paths \ No newline at end of file diff --git a/frontend/app.vue b/frontend/app.vue new file mode 100644 index 0000000..fa4ee3e --- /dev/null +++ b/frontend/app.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/assets/css/common.css b/frontend/assets/css/common.css new file mode 100644 index 0000000..17d56ce --- /dev/null +++ b/frontend/assets/css/common.css @@ -0,0 +1,9 @@ +*{ + box-sizing: border-box; +} +body{ + position: relative; + padding: 0px; + margin: 0px; + overflow-x: hidden; +} \ No newline at end of file diff --git a/frontend/comman-file.txt b/frontend/comman-file.txt new file mode 100644 index 0000000..f03d8cb --- /dev/null +++ b/frontend/comman-file.txt @@ -0,0 +1,6 @@ +Manually start a dnsmasq instance on localhost only +========== + +sudo dnsmasq --no-daemon --keep-in-foreground --conf-dir=/etc/dnsmasq-local --listen-address=127.0.0.1 --bind-interfaces + +========== diff --git a/frontend/components/Captcha.vue b/frontend/components/Captcha.vue new file mode 100644 index 0000000..8e38a6b --- /dev/null +++ b/frontend/components/Captcha.vue @@ -0,0 +1,130 @@ + + + + + + + diff --git a/frontend/components/Spinner.vue b/frontend/components/Spinner.vue new file mode 100644 index 0000000..2e68491 --- /dev/null +++ b/frontend/components/Spinner.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/lxd-app.code-workspace b/frontend/lxd-app.code-workspace new file mode 100644 index 0000000..2a0ed79 --- /dev/null +++ b/frontend/lxd-app.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": ".." + } + ] +} \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..599491f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,94 @@ +server { + listen 8080; + server_name lxdapp.local *.lxdapp.local; + + root /var/www/html/lxd-app/backend/app/public; + index index.php index.html; + + access_log /var/log/nginx/lxdapp.access.log; + error_log /var/log/nginx/lxdapp.error.log; + + location / { + try_files $uri /index.php$is_args$args; + } + + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param SCRIPT_NAME $fastcgi_script_name; + fastcgi_index index.php; + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + } + + location ~ /\.ht { + deny all; + } +} + + +server { + listen 8081 ssl; + server_name lxdapp.local; + + ssl_certificate /etc/ssl/certs/lxdapp.local.crt; + ssl_certificate_key /etc/ssl/private/lxdapp.local.key; + + root /var/www/html/lxd-app/backend/app/public; + index index.php index.html; + + access_log /var/log/nginx/lxdapp.access.log; + error_log /var/log/nginx/lxdapp.error.log; + + location / { + try_files $uri /index.php$is_args$args; + } + + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param SCRIPT_NAME $fastcgi_script_name; + fastcgi_index index.php; + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + } + + location ~ /\.ht { + deny all; + } +} + +public function forward(Request $request, Response $response) +{ + $source = $request->getQueryParams()['source'] ?? null; + $domain = $request->getUri()->getHost(); // e.g. customer1.lxdapp.local + + $name = $this->mapDomainToContainer($domain); // Use your config mapping + $ip = $this->getContainerIP($name); + + if ($source === 'login') { + // Trigger provisioning if requested from login + if (!$this->containerExists($name)) { + $this->createContainer($name); + $this->startContainer($name); + $this->installPackages($name); + + // Wait for container to be ready + if (!$this->waitForPort($ip, 80)) { + return $this->json($response, ['status' => 'error', 'message' => 'Container not ready'], 500); + } + } + + return $this->json($response, ['status' => 'success', 'ip' => $ip]); + } + + // For non-login (main page access) + if (!$this->containerExists($name)) { + return $this->json($response, ['status' => 'not-found']); + } + + // Otherwise proxy the request to container (assuming already up) + return $this->proxyToContainer($request, $response, $ip); +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts new file mode 100644 index 0000000..00b59e2 --- /dev/null +++ b/frontend/nuxt.config.ts @@ -0,0 +1,16 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-05-15', + devtools: { enabled: true }, + runtimeConfig: { + public: { + siteUrl: process.env.SITE_URL, + backendUrl: process.env.BACKEND_URL, + apiVersion: process.env.API_VERSION, + apiUrl: `${process.env.BACKEND_URL}/${process.env.API_VERSION}` + } + }, + css: [ + '~/assets/css/common.css' // Path to your global CSS file + ], +}) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..78edf33 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "nuxt-app", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev --host 0.0.0.0", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/vue-fontawesome": "^3.0.8", + "nuxt": "^3.17.6", + "vue": "^3.5.17", + "vue-router": "^4.5.1" + } +} diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue new file mode 100644 index 0000000..731f261 --- /dev/null +++ b/frontend/pages/index.vue @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue new file mode 100644 index 0000000..611b2cc --- /dev/null +++ b/frontend/pages/login.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/plugins/fontawesome.client.ts b/frontend/plugins/fontawesome.client.ts new file mode 100644 index 0000000..b062b7c --- /dev/null +++ b/frontend/plugins/fontawesome.client.ts @@ -0,0 +1,10 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { faSyncAlt } from '@fortawesome/free-solid-svg-icons' + +// Add the icons you need +library.add(faSyncAlt) + +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.component('FontAwesomeIcon', FontAwesomeIcon) +}) diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..18993ad Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..0ad279c --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-Agent: * +Disallow: diff --git a/frontend/server/tsconfig.json b/frontend/server/tsconfig.json new file mode 100644 index 0000000..b9ed69c --- /dev/null +++ b/frontend/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a746f2a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +}