update logic and refactor code

This commit is contained in:
2025-07-16 12:15:55 +02:00
parent ed6443347a
commit 6d055f4fad
28 changed files with 435 additions and 727 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
backend/app/.DS_Store vendored

Binary file not shown.

View File

@ -4,3 +4,5 @@ LXD_API_URL=https://localhost:8443
LXD_CLIENT_CERT=/etc/ssl/lxdapp/client.crt LXD_CLIENT_CERT=/etc/ssl/lxdapp/client.crt
LXD_CLIENT_KEY=/etc/ssl/lxdapp/client.key LXD_CLIENT_KEY=/etc/ssl/lxdapp/client.key
LXD_IMAGE_FINGERPRINT=2edfd84b1396 LXD_IMAGE_FINGERPRINT=2edfd84b1396
AUTH_KEY=R9kX2HFA7ZjLdVYm8TsQWpCeNuB1v0GrS6MI4axf

View File

@ -61,27 +61,8 @@ backend/
3. **Ensure PHP and NGINX are configured** 3. **Ensure PHP and NGINX are configured**
NGINX config example: Check in backend/nginx.conf
```bash
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** 4. **Map domain in /etc/hosts**

View File

@ -3,9 +3,8 @@
"slim/slim": "4.*", "slim/slim": "4.*",
"slim/psr7": "^1.7", "slim/psr7": "^1.7",
"php-di/php-di": "^7.0", "php-di/php-di": "^7.0",
"guzzlehttp/guzzle": "^7.9", "vlucas/phpdotenv": "^5.6",
"nyholm/psr7": "^1.8", "zounar/php-proxy": "^1.1"
"vlucas/phpdotenv": "^5.6"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

View File

@ -1,3 +1,3 @@
{ {
"test.lxdapp.local": "container-test" "testone.lxdapp.local": "testone"
} }

45
backend/app/nginx.conf Normal file
View File

@ -0,0 +1,45 @@
server {
listen 80;
server_name *.lxdapp.local lxdapp.local;
root /var/www/html/lxd-app/backend/app/public; # Adjust this to your Slim project's public folder
index index.php index.html index.htm;
# Reverse proxy to Vue.js for /app/ route
location ^~ /app/ {
proxy_pass http://127.0.0.1:3000/app/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# rewrite ^/app/(/.*)$ $1 break;
}
# (Optional: Serve static assets directly if needed)
# location /app/_nuxt/ {
# proxy_pass http://127.0.0.1:3000;
# }
# Handle PHP Slim
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Pass PHP scripts to PHP-FPM
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Static file optimization (optional)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|otf)$ {
expires 30d;
access_log off;
}
error_log /var/log/nginx/tutorial_error.log;
access_log /var/log/nginx/tutorial_access.log;
}

View File

@ -5,6 +5,12 @@ declare(strict_types=1);
use DI\ContainerBuilder; use DI\ContainerBuilder;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
use Dotenv\Dotenv; use Dotenv\Dotenv;
use App\Middleware\CorsMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use App\Controllers\CaptchaController;
use App\Controllers\ProxyController;
use App\Controllers\LoginController;
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
@ -32,11 +38,11 @@ $containerBuilder->useAutowiring(true); // Enable autowiring globally
// Add settings // Add settings
$settings = require __DIR__ . '/../src/Settings/settings.php'; $settings = require __DIR__ . '/../src/Settings/Settings.php';
$settings($containerBuilder); $settings($containerBuilder);
// Add dependencies // Add dependencies
$dependencies = require __DIR__ . '/../src/Dependencies/dependencies.php'; $dependencies = require __DIR__ . '/../src/Dependencies/Dependencies.php';
$dependencies($containerBuilder); $dependencies($containerBuilder);
// Build the container // Build the container
@ -49,22 +55,23 @@ AppFactory::setContainer($container);
$app = AppFactory::create(); $app = AppFactory::create();
// 🔹 CORS middleware // 🔹 CORS middleware
$app->add(function ($request, $handler) { $app->add(CorsMiddleware::class);
$response = $handler->handle($request);
$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 // Register middleware
(require __DIR__ . '/../src/Bootstrap/middleware.php')($app); (require __DIR__ . '/../src/Bootstrap/Middleware.php')($app);
// Register routes // Register routes
(require __DIR__ . '/../src/Bootstrap/routes.php')($app);
// API contianer proxy route
$app->group('/api', function ($group) {
$group->get('/captcha', [CaptchaController::class, 'get']);
$group->post('/login', [LoginController::class, 'index']);
});
$app->any('/{routes:.*}', [ProxyController::class, 'forward']);
// Run app // Run app
$app->run(); $app->run();

Binary file not shown.

View File

@ -0,0 +1,27 @@
2025-07-15 18:59:58 : http://lxdapp.local/api/login
2025-07-15 18:59:59 : http://10.110.90.24//demo
2025-07-15 19:00:26 : http://10.110.90.24//
2025-07-15 19:01:46 : http://lxdapp.local/api/login
2025-07-15 19:01:47 : http://10.110.90.24//
2025-07-15 19:02:21 : http://10.110.90.24//
2025-07-15 19:04:09 : http://lxdapp.local/api/login
2025-07-15 19:04:10 : http://10.110.90.24//
2025-07-15 19:11:18 : http://lxdapp.local/api/login
2025-07-15 19:11:18 : http://10.110.90.24//
2025-07-15 19:12:44 : http://10.110.90.24//sdfsd
2025-07-15 19:13:12 : http://10.110.90.24//
2025-07-15 19:17:17 : http://10.110.90.24//
2025-07-15 19:25:26 : http://testone.lxdapp.local/api/login
2025-07-15 19:25:28 : http://10.110.90.24//
2025-07-16 09:15:04 : http://10.110.90.24//
2025-07-16 09:21:18 : http://10.110.90.24//
2025-07-16 09:22:08 : http://testone.lxdapp.local/api/login
2025-07-16 09:22:09 : http://10.110.90.24//
2025-07-16 09:30:13 : http://10.110.90.24//
2025-07-16 09:32:41 : http://testone.lxdapp.local/api/login
2025-07-16 09:32:42 : http://10.110.90.24//
2025-07-16 09:33:50 : http://testone.lxdapp.local/api/login
2025-07-16 09:33:50 : http://10.110.90.24//
2025-07-16 09:33:58 : http://10.110.90.24//
2025-07-16 09:45:10 : http://10.110.90.24//
2025-07-16 09:45:12 : http://10.110.90.24//

View File

@ -1,11 +0,0 @@
<?php
use Slim\App;
return function (App $app) {
// Web routes
(require __DIR__ . '/../Routes/web.php')($app);
// API routes
(require __DIR__ . '/../Routes/v1/proxy.php')($app);
};

View File

@ -2,7 +2,7 @@
namespace App\Controllers; namespace App\Controllers;
use App\lib\PCaptcha; use App\Lib\PCaptcha;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;

View File

@ -0,0 +1,70 @@
<?php
namespace App\Controllers;
use App\Lib\PCaptcha;
use App\Services\LxdService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use App\Utils\LogWriterHelper;
class LoginController
{
/**
* Login with captcha
*/
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$mainDomain = $_ENV['MAIN_DOMAIN'] ?? 'lxdapp.local';
$origin = $request->getHeaderLine('Origin');
if (!empty($origin)) {
$domain = parse_url($origin, PHP_URL_HOST);
} else {
$domain = $request->getHeaderLine('Host');
}
$configPath = __DIR__ . '/../../config.json';
$config = file_exists($configPath) ? json_decode(file_get_contents($configPath), true) : [];
$name = $config[$domain] ?? null;
$params = (array)$request->getParsedBody();
$captcha = new PCaptcha();
if (!$captcha->validate_captcha($params['panswer'])) {
return $this->json($response, ['status' => 'error', 'message' => 'Invalid CAPTCHA'], 200);
}
$lxd = new LxdService();
$status = $lxd->getContainerState($name)['metadata']['status'] ?? 'Stopped';
if ($status !== 'Running') {
$lxd->startContainer($name);
sleep(10);
}
// Write log
LogWriterHelper::write($name, $request->getUri());
// Login success
return $this->json($response, ['status' => 'success', 'message' => 'Container started!']);
}
/**
* Sends a JSON response.
*/
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);
}
}

View File

@ -4,10 +4,10 @@ namespace App\Controllers;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use GuzzleHttp\Client;
use App\Utils\SubdomainHelper;
use App\Services\LxdService; use App\Services\LxdService;
use App\lib\PCaptcha; use App\Lib\PCaptcha;
use Zounar\PHPProxy\Proxy;
use App\Utils\LogWriterHelper;
class ProxyController class ProxyController
{ {
@ -16,102 +16,62 @@ class ProxyController
*/ */
public function forward(Request $request, Response $response): Response public function forward(Request $request, Response $response): Response
{ {
$mainDomain = $_ENV['MAIN_DOMAIN'] ?? 'lxdapp.local'; try {
$mainDomain = $_ENV['MAIN_DOMAIN'] ?? 'lxdapp.local';
$origin = $request->getHeaderLine('Origin'); $origin = $request->getHeaderLine('Origin');
$domain = parse_url($origin, PHP_URL_HOST); // e.g. customer.lxdapp.local if (!empty($origin)) {
$params = (array)$request->getParsedBody(); $domain = parse_url($origin, PHP_URL_HOST);
} else {
$domain = $request->getHeaderLine('Host');
}
$configPath = __DIR__ . '/../../config.json'; $configPath = __DIR__ . '/../../config.json';
$config = file_exists($configPath) ? json_decode(file_get_contents($configPath), true) : []; $config = file_exists($configPath) ? json_decode(file_get_contents($configPath), true) : [];
$name = $config[$domain] ?? null; $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(); $lxd = new LxdService();
if (!$captcha->validate_captcha($params['panswer'])) {
return $this->json($response, ['status' => 'error', 'error' => 'invalid_captcha']);
}
// STEP 1: If container mapping not found
if (!$name) { if (!$name) {
$subdomain = SubdomainHelper::getSubdomain($domain, $mainDomain); return $this->json($response, ['status' => 'error', 'message' => 'Container does not exist.'], 404);
$name = $this->generateContainerName($subdomain); // or just use subdomain
$config[$domain] = $name;
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT));
} }
// STEP 2: Check if container exists in LXD
if (!$lxd->containerExists($name)) { if (!$lxd->containerExists($name)) {
$lxd->createContainerAndWait($name); return $this->json($response, ['status' => 'error', 'message' => 'Container does not exist.'], 404);
sleep(5);
//$lxd->startContainer($name);
$lxd->installPackages($name);
sleep(5);
} }
// STEP 4: Check container status
$containerInfo = $lxd->getContainerState($name);
$status = $containerInfo['metadata']['status'] ?? 'Stopped';
if ($status !== 'Running') {
// Container not running → redirect to login page
$scheme = $request->getUri()->getScheme(); // "http" or "https"
$redirectUrl = $request->getUri();
return $response
->withHeader('Location', 'app/?auth=ok&redirect=' . urlencode($redirectUrl))
->withStatus(302);
}
// STEP 5: Container is running → proxy request
$ip = $lxd->getContainerIP($name); $ip = $lxd->getContainerIP($name);
if (!$ip) { if (!$ip) {
return $this->json($response, [ return $this->json($response, ['status' => 'error', 'message' => 'Could not fetch container IP'], 500);
'status' => 'error',
'message' => "Failed to get container IP for '$name'"
], 500);
} }
// if (!$lxd->waitForPort($ip, 80, 30)) { return $this->proxyToContainer($request, $response, $ip, $name);
// return $this->json($response, ['status' => 'error', 'message' => 'Container not ready'], 500); } catch (\Throwable $e) {
// } // Global fallback for any exception
return $this->json($response, [
return $this->json($response, ['status' => 'success', 'ip' => $ip]); 'status' => 'error',
'message' => 'Internal Server Error: ' . $e->getMessage()
], 500);
} }
// 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);
} }
/**
* Maps a domain to a container 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;
}
/** /**
* Sends a JSON response. * Sends a JSON response.
@ -128,130 +88,43 @@ class ProxyController
->withStatus($status); ->withStatus($status);
} }
/**
* Generates a sanitized container name based on a subdomain.
*/
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}";
}
/** /**
* Proxies the request to the container. * Proxies the request to the container.
*/ */
private function proxyToContainer(Request $request, Response $response, string $ip, string $name): Response private function proxyToContainer(Request $request, Response $response, string $ip, string $name): Response
{ {
$client = new Client([
'http_errors' => false,
'timeout' => 10,
]);
$method = $request->getMethod();
$baseUrl = $ip ? "http://$ip" : "http://127.0.0.1:3000";
$baseUrl = $ip ? "http://$ip/" : "http://127.0.0.1:3000";
$uri = $request->getUri(); $uri = $request->getUri();
$path = $uri->getPath(); $path = $uri->getPath();
// Remove /api/v1 prefix from path // Remove /api/v1 prefix from path
$prefix = '/api/v1'; $prefix = '/api/';
if (strpos($path, $prefix) === 0) { if (strpos($path, $prefix) === 0) {
$path = substr($path, strlen($prefix)); $path = substr($path, strlen($prefix));
if ($path === '') { if ($path === '') {
$path = '/'; $path = '/';
} }
} }
// Add .php extension if not present
if (!str_ends_with($path, '.php')) {
$path .= '.php';
}
$query = $uri->getQuery(); $query = $uri->getQuery();
$targetUrl = $baseUrl . $path . ($query ? '?' . $query : ''); $targetUrl = $baseUrl . $path . ($query ? '?' . $query : '');
$options = [
'headers' => [],
'http_errors' => false,
'debug' => false,
];
foreach ($request->getHeaders() as $headerName => $values) { Proxy::$AUTH_KEY = $_ENV['AUTH_KEY'] ?? 'Bj5pnZEX6DkcG6Nz6AjDUT1bvcGRVhRaXDuKDX9CjsEs2';
if (strtolower($headerName) !== 'host') { Proxy::$ENABLE_AUTH = true; // Enable auth
$options['headers'][$headerName] = implode(', ', $values); Proxy::$HEADER_HTTP_PROXY_AUTH = 'HTTP_PROXY_AUTH'; // Ensure it matches the key
}
}
$options['headers'] = [ $_SERVER['HTTP_PROXY_AUTH'] = $_ENV['AUTH_KEY'] ?? 'Bj5pnZEX6DkcG6Nz6AjDUT1bvcGRVhRaXDuKDX9CjsEs2';
'Host' => (string) $ip, $_SERVER['HTTP_PROXY_TARGET_URL'] = $targetUrl;
'Accept-Encoding' => 'gzip, deflate', // Do your custom logic before running proxy
'Accept' => '*/*', $responseCode = Proxy::run();
'User-Agent' => 'SlimProxy/1.0',
];
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) { // Write log
$body = (string) $request->getBody(); LogWriterHelper::write($name, $targetUrl);
if ($body) {
$options['body'] = $body;
}
}
$forwarded = $client->request($method, $targetUrl, $options);
$this->writeLastAccessLog($name, $targetUrl); return $response;
foreach ($forwarded->getHeaders() as $headerName => $headerValues) {
$response = $response->withHeader($headerName, implode(', ', $headerValues));
}
$response->getBody()->write((string) $forwarded->getBody());
return $response->withStatus($forwarded->getStatusCode());
} }
private function proxyToContainerOLD(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());
}
/**
* Logs the last access to a container.
*/
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);
}
} }

View File

@ -0,0 +1,28 @@
<?php
namespace App\Middleware;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Response;
class CorsMiddleware implements MiddlewareInterface
{
public function process(Request $request, RequestHandler $handler): ResponseInterface
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
if ($request->getMethod() === 'OPTIONS') {
$response = new Response(204);
} else {
$response = $handler->handle($request);
}
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');
}
}

View File

@ -1,11 +0,0 @@
<?php
use Slim\App;
use App\Controllers\ProxyController;
return function (App $app) {
// Proxy route
$app->group('/api/v1', function ($group) {
$group->any('/{routes:.*}', [ProxyController::class, 'forward']);
});
};

View File

@ -1,19 +0,0 @@
<?php
use Slim\App;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use App\Controllers\CaptchaController;
return function (App $app) {
// Home route
$app->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']);
};

View File

@ -6,11 +6,9 @@ use Exception;
class LxdService class LxdService
{ {
private string $baseUrl; private string $baseUrl;
private string $imageFingerprint;
public function __construct() { public function __construct() {
$this->baseUrl = $_ENV['LXD_API_URL'] ?? 'https://localhost:8443'; $this->baseUrl = $_ENV['LXD_API_URL'] ?? 'https://localhost:8443';
$this->imageFingerprint = $_ENV['LXD_IMAGE_FINGERPRINT'] ?? '2edfd84b1396';
} }
@ -70,20 +68,6 @@ class LxdService
return $json; return $json;
} }
/**
* Retrieves a container.
*
* @param string $name Container name
* @return array|null Container or null if an error occurs
*/
public function getContainer(string $name) {
try {
return $this->request('GET', "/1.0/instances/$name");
} catch (\Throwable $e) {
return null;
}
}
/** /**
* Retrieves the status of a container. * Retrieves the status of a container.
* *
@ -139,9 +123,7 @@ class LxdService
throw new \Exception("Failed to start container: " . json_encode($startOpResp)); throw new \Exception("Failed to start container: " . json_encode($startOpResp));
} }
// 6. Return final container info return $startResponse;
$containerResponse = $this->getContainer($name);
return $containerResponse['metadata'] ?? [];
} }
/** /**
@ -159,111 +141,6 @@ class LxdService
return $response; return $response;
} }
/**
* Creates a new container.
*
* @param string $name Container name
* @param string $fingerprint Image fingerprint
* @return array Response from the API
*/
public function createContainer(string $name): array {
$response = $this->request('POST', "/1.0/instances", [
"name" => $name,
"source" => [
"type" => "image",
"fingerprint" => $this->imageFingerprint
]
]);
sleep(5);
return $response;
}
/**
* Installs packages inside a container.
*
* @param string $name Container name
* @throws Exception
*/
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 services to start
$this->waitForService($name, 'nginx');
$this->waitForService($name, 'mysql');
}
/**
* Waits for a service to become active inside a container.
*
* @param string $name Container name
* @param string $service Service name
* @throws Exception
*/
private function waitForService(string $name, string $service)
{
for ($i = 0; $i < 10; $i++) {
$status = shell_exec("/snap/bin/lxc exec $name -- systemctl is-active $service");
if (trim($status) === 'active') {
return;
}
sleep(2);
}
throw new Exception("Failed to start $service inside container $name");
}
/**
* Creates a new container and wait for start.
*
* @param string $name Container name
* @param string $fingerprint Image fingerprint
* @return array Response from the API
*/
public function createContainerAndWait(string $name, array $config = []): array {
$body = [
"name" => $name,
"source" => array_merge([
"type" => "image",
"fingerprint" => $this->imageFingerprint
]),
];
$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);
}
/** /**
@ -278,6 +155,27 @@ class LxdService
return $this->getIPv4FromMetadata($container['metadata']); return $this->getIPv4FromMetadata($container['metadata']);
} }
/**
* Extracts the IPv4 address from container metadata.
*
* @param array $metadata Container metadata
* @return string|null IPv4 address or null if not found
*/
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;
}
/** /**
* Waits for a specific port to become available. * Waits for a specific port to become available.
* *
@ -303,26 +201,4 @@ class LxdService
return false; // Timed out return false; // Timed out
} }
/**
* Extracts the IPv4 address from container metadata.
*
* @param array $metadata Container metadata
* @return string|null IPv4 address or null if not found
*/
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;
}
} }

View File

@ -0,0 +1,21 @@
<?php
namespace App\Utils;
class LogWriterHelper {
public static function write(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);
}
}

View File

@ -1,14 +0,0 @@
<?php
namespace App\Utils;
class SubdomainHelper {
public static function getSubdomain(string $host, string $mainDomain): ?string {
if (!str_ends_with($host, $mainDomain)) {
return null;
}
$subPart = substr($host, 0, -strlen($mainDomain) - 1);
$parts = explode('.', $subPart);
return $parts[0] ?? null;
}
}

View File

@ -1,5 +1,5 @@
<?php <?php
namespace App\lib; namespace App\Lib;
/** /**
* PCaptcha : * PCaptcha :
* A simple/lightweight class provides you with the necessary tools to generate a friendly/secure captcha and validate it * A simple/lightweight class provides you with the necessary tools to generate a friendly/secure captcha and validate it

View File

@ -6,7 +6,7 @@ This is the **frontend Nuxt.js** application that works with the [LXD Proxy API]
## 🌐 Overview ## 🌐 Overview
- Each user has a subdomain (e.g., `customer1.lxdapp.local`) - Each user has a subdomain (e.g., `mitul.lxdapp.local`)
- On visiting the subdomain, a login page prompts for CAPTCHA - On visiting the subdomain, a login page prompts for CAPTCHA
- Upon successful CAPTCHA, a request is made to the backend to: - Upon successful CAPTCHA, a request is made to the backend to:
- Create or start an LXD container - Create or start an LXD container
@ -34,11 +34,6 @@ This is the **frontend Nuxt.js** application that works with the [LXD Proxy API]
```bash ```bash
npm run dev 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** **🗂 Project Structure**
@ -54,39 +49,3 @@ frontend/
├── package.json ├── package.json
└── README.md └── 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

View File

@ -1,6 +0,0 @@
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
==========

View File

@ -1,7 +0,0 @@
{
"folders": [
{
"path": ".."
}
]
}

View File

@ -1,94 +0,0 @@
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);
}

View File

@ -1,13 +1,23 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
app: {
baseURL: '/app/' // This replaces router.base from Nuxt 2
},
ssr: true,
compatibilityDate: '2025-05-15', compatibilityDate: '2025-05-15',
devtools: { enabled: true }, devtools: { enabled: true },
runtimeConfig: { runtimeConfig: {
public: { public: {
siteUrl: process.env.SITE_URL, siteUrl: process.env.SITE_URL,
backendUrl: process.env.BACKEND_URL, backendUrl: process.env.BACKEND_URL,
apiVersion: process.env.API_VERSION, apiUrl: `${process.env.BACKEND_URL}/api`
apiUrl: `${process.env.BACKEND_URL}/${process.env.API_VERSION}` }
},
nitro: {
prerender: {
crawlLinks: true,
routes: ['/', '/login'], // Add known routes
ignore: ['/*.php'], // Or leave empty to catch with [...slug].vue
} }
}, },
css: [ css: [

View File

@ -2,44 +2,97 @@
<div class="page-wrapper"> <div class="page-wrapper">
<Spinner :loading="loading" /> <Spinner :loading="loading" />
<div v-html="output" /> <div v-html="output" />
<!-- 👇 Only show form if access is allowed -->
<div v-if="allowAccess" class="login-container">
<form @submit.prevent="submitForm" class="login-form">
<h2 class="title">Login</h2>
<!-- Include Captcha -->
<Captcha v-model:captcha="captchaValue" />
<button type="submit" :disabled="loading || !captchaValue" class="btn">
<span v-if="!loading">Login</span>
<span v-else>Loading...</span>
</button>
<br/>
<p v-if="captchaError" class="error-text">{{ captchaError }}</p>
</form>
</div>
<!-- 👇 Show this if the user did not come from an approved source -->
<div v-else class="login-container">
<h2 class="title">Access Denied</h2>
<p>You must access this page through the proper login flow.</p>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Spinner from '@/components/Spinner.vue'; import Spinner from '@/components/Spinner.vue';
import Captcha from '@/components/Captcha.vue';
const router = useRouter();
const loading = ref(true);
const output = ref(''); const output = ref('');
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const captchaValue = ref('');
const captchaError = ref('');
const redirectTo = ref('/');
const allowAccess = ref(false) // 🔐 This controls what to show
// 🟡 Grab redirect param on load
onMounted(() => {
const redirectParam = route.query.redirect;
const authParam = route.query.auth;
if (redirectParam && typeof redirectParam === 'string') {
redirectTo.value = decodeURIComponent(redirectParam);
}
// ✅ More reliable than document.referrer
if (authParam === 'ok') {
allowAccess.value = true;
}
});
const submitForm = async () => {
loading.value = true;
output.value = '';
const config = useRuntimeConfig();
onMounted(async () => {
try { try {
const config = useRuntimeConfig(); const res = await $fetch(`${window.location.origin}/api/login`, {
// fetch without forcing responseType to text, so it defaults to JSON if possible method: 'POST',
const res = await $fetch(`${config.public.apiUrl}/proxy`); headers: {
'Content-Type': 'application/json',
},
body: {
source: 'login',
panswer: captchaValue.value,
redirect: redirectTo.value,
},
credentials: 'include',
throwHttpErrors: false, // important: do NOT throw on 401/4xx
});
if (typeof res === 'object') { if (res.status === 'success') {
if(res?.status === "not-found"){ captchaError.value = '';
router.push('/login'); if (redirectTo.value.startsWith('http://') || redirectTo.value.startsWith('https://')) {
}else{ window.location.href = redirectTo.value;
// pretty print JSON response } else {
output.value = `<pre>${JSON.stringify(res, null, 2)}</pre>`; router.push(redirectTo.value);
} }
} else if (res.status === 'error' && res.message === 'Invalid CAPTCHA') {
captchaError.value = '❌ Invalid CAPTCHA. Please try again.';
} else { } else {
// treat as plain text or HTML captchaError.value = res.message || 'Login failed. Please try again.';
output.value = res;
} }
} catch (error) { } catch (error) {
output.value = `<p style="color:red;">Container access failed.</p>`; // This should rarely happen now because throwHttpErrors is false
captchaError.value = error.message || 'Network error.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); };
</script> </script>
<style scoped> <style scoped>
.page-wrapper { .page-wrapper {
width: 100%; width: 100%;
@ -48,8 +101,57 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0px; padding: 0;
margin: 0px; margin: 0;
text-align: center; text-align: center;
} }
</style> .login-container {
max-width: 100%;
width: 400px;
margin: 5rem auto;
padding: 2rem;
border-radius: 8px;
background: #f9fafb;
box-shadow: 0 4px 10px rgb(0 0 0 / 0.1);
}
.title {
text-align: center;
margin-bottom: 1.5rem;
font-weight: 700;
font-size: 1.8rem;
color: #222;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: 1.2rem;
}
.btn {
width: 100%;
padding: 0.8rem;
font-weight: 600;
font-size: 1.1rem;
background-color: #3b82f6; /* blue-500 */
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover:not(:disabled) {
background-color: #2563eb; /* blue-600 */
}
.btn:disabled {
background-color: #93c5fd; /* blue-300 */
cursor: not-allowed;
}
.error-text {
color: red;
font-size: 0.95rem;
margin-bottom: 1rem;
text-align: center;
}
</style>

View File

@ -1,130 +0,0 @@
<template>
<div class="page-wrapper">
<Spinner :loading="loading" />
<div v-html="output" />
<div class="login-container">
<form @submit.prevent="submitForm" class="login-form">
<h2 class="title">Login</h2>
<!-- Include Captcha -->
<Captcha v-model:captcha="captchaValue" />
<button type="submit" :disabled="loading || !captchaValue" class="btn">
<span v-if="!loading">Login</span>
<span v-else>Loading...</span>
</button>
<br/>
<p v-if="captchaError" class="error-text">{{ captchaError }}</p>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import Spinner from '@/components/Spinner.vue';
import Captcha from '@/components/Captcha.vue';
const output = ref('');
const router = useRouter();
const loading = ref(false);
const captchaValue = ref('');
const captchaError = ref('');
const submitForm = async () => {
loading.value = true;
output.value = '';
const config = useRuntimeConfig();
try {
const res = await $fetch(`${config.public.apiUrl}/proxy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
source: 'login',
panswer: captchaValue.value
},
credentials: 'include',
});
if (res?.status === 'success') {
captchaError.value = '';
router.push('/');
} else if (res?.error === 'invalid_captcha') {
captchaError.value = '❌ Invalid CAPTCHA. Please try again.';
} else {
captchaError.value = res.message;
}
} catch (error) {
captchaError.value = res.message;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.page-wrapper {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
text-align: center;
}
.login-container {
max-width: 100%;
width: 400px;
margin: 5rem auto;
padding: 2rem;
border-radius: 8px;
background: #f9fafb;
box-shadow: 0 4px 10px rgb(0 0 0 / 0.1);
}
.title {
text-align: center;
margin-bottom: 1.5rem;
font-weight: 700;
font-size: 1.8rem;
color: #222;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: 1.2rem;
}
.btn {
width: 100%;
padding: 0.8rem;
font-weight: 600;
font-size: 1.1rem;
background-color: #3b82f6; /* blue-500 */
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
}
.btn:hover:not(:disabled) {
background-color: #2563eb; /* blue-600 */
}
.btn:disabled {
background-color: #93c5fd; /* blue-300 */
cursor: not-allowed;
}
.error-text {
color: red;
font-size: 0.95rem;
margin-bottom: 1rem;
text-align: center;
}
</style>