forked from urvishpatelce/lxd-app
initialize Project
This commit is contained in:
BIN
backend/.DS_Store
vendored
Normal file
BIN
backend/.DS_Store
vendored
Normal file
Binary file not shown.
156
backend/app/README.md
Normal file
156
backend/app/README.md
Normal file
@ -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.
|
||||||
|
|
||||||
|
|
||||||
14
backend/app/composer.json
Normal file
14
backend/app/composer.json
Normal file
@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backend/app/config.json
Normal file
3
backend/app/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"customer1.lxdapp.local": "container-customer1"
|
||||||
|
}
|
||||||
3
backend/app/public/.htaccess
Normal file
3
backend/app/public/.htaccess
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
||||||
67
backend/app/public/index.php
Normal file
67
backend/app/public/index.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
$domain = '.lxdapp.local'; // Leading dot is important for subdomain support
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 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();
|
||||||
9
backend/app/public/last-access-logs/container-mitul.txt
Normal file
9
backend/app/public/last-access-logs/container-mitul.txt
Normal file
@ -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
|
||||||
40
backend/app/src/Bootstrap/middleware.php
Normal file
40
backend/app/src/Bootstrap/middleware.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Slim\App;
|
||||||
|
use Slim\Middleware\ErrorMiddleware;
|
||||||
|
use Slim\Middleware\BodyParsingMiddleware;
|
||||||
|
|
||||||
|
return function (App $app) {
|
||||||
|
// Add body parsing middleware (for JSON, form, etc.)
|
||||||
|
$app->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);
|
||||||
|
};
|
||||||
11
backend/app/src/Bootstrap/routes.php
Normal file
11
backend/app/src/Bootstrap/routes.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?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);
|
||||||
|
};
|
||||||
34
backend/app/src/Controllers/CaptchaController.php
Normal file
34
backend/app/src/Controllers/CaptchaController.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\lib\PCaptcha;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class CaptchaController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generates and outputs a captcha image.
|
||||||
|
*/
|
||||||
|
public function get(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
$captcha = new PCaptcha();
|
||||||
|
$captcha->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/app/src/Controllers/ProxyController.php
Normal file
173
backend/app/src/Controllers/ProxyController.php
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Managers\LXDProxyManager;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use App\Utils\SubdomainHelper;
|
||||||
|
use App\Services\LxdService;
|
||||||
|
use App\lib\PCaptcha;
|
||||||
|
|
||||||
|
class ProxyController
|
||||||
|
{
|
||||||
|
public function forward(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$mainDomain = 'lxdapp.local';
|
||||||
|
|
||||||
|
$origin = $request->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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
12
backend/app/src/Dependencies/dependencies.php
Normal file
12
backend/app/src/Dependencies/dependencies.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use function DI\autowire;
|
||||||
|
use App\Services\LxdService;
|
||||||
|
|
||||||
|
return function (ContainerBuilder $containerBuilder) {
|
||||||
|
// Example service binding
|
||||||
|
$containerBuilder->addDefinitions([
|
||||||
|
\App\Services\LxdService::class => autowire()
|
||||||
|
]);
|
||||||
|
};
|
||||||
143
backend/app/src/Managers/LXDProxyManager.php
Normal file
143
backend/app/src/Managers/LXDProxyManager.php
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Managers;
|
||||||
|
use App\Services\LxdService;
|
||||||
|
|
||||||
|
class LXDProxyManager
|
||||||
|
{
|
||||||
|
protected LxdService $lxdService;
|
||||||
|
protected string $mapFile = '/etc/nginx/lxd_map.conf';
|
||||||
|
|
||||||
|
public function __construct(LxdService $lxdService)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
backend/app/src/Routes/v1/proxy.php
Normal file
11
backend/app/src/Routes/v1/proxy.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Slim\App;
|
||||||
|
use App\Controllers\ProxyController;
|
||||||
|
|
||||||
|
return function (App $app) {
|
||||||
|
// Proxy route
|
||||||
|
$app->group('/api/v1', function ($group) {
|
||||||
|
$group->any('/proxy', [ProxyController::class, 'forward']);
|
||||||
|
});
|
||||||
|
};
|
||||||
19
backend/app/src/Routes/web.php
Normal file
19
backend/app/src/Routes/web.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?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']);
|
||||||
|
|
||||||
|
};
|
||||||
262
backend/app/src/Services/LxdService.php
Normal file
262
backend/app/src/Services/LxdService.php
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class LxdService
|
||||||
|
{
|
||||||
|
private string $baseUrl;
|
||||||
|
private string $socketPath;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
// $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/app/src/Settings/settings.php
Normal file
11
backend/app/src/Settings/settings.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
|
||||||
|
return function (ContainerBuilder $containerBuilder) {
|
||||||
|
$containerBuilder->addDefinitions([
|
||||||
|
'settings' => [
|
||||||
|
'displayErrorDetails' => true, // turn off in production
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
};
|
||||||
14
backend/app/src/Utils/SubdomainHelper.php
Normal file
14
backend/app/src/Utils/SubdomainHelper.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
239
backend/app/src/lib/PCaptcha.php
Normal file
239
backend/app/src/lib/PCaptcha.php
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\lib;
|
||||||
|
/**
|
||||||
|
* PCaptcha :
|
||||||
|
* A simple/lightweight class provides you with the necessary tools to generate a friendly/secure captcha and validate it
|
||||||
|
*
|
||||||
|
* @author Bader Almutairi (Phpfalcon)
|
||||||
|
* @link https://github.com/phpfalcon/pcaptcha/
|
||||||
|
* @license http://opensource.org/licenses/MIT MIT License
|
||||||
|
*/
|
||||||
|
|
||||||
|
class PCaptcha
|
||||||
|
{
|
||||||
|
//options
|
||||||
|
var $width = 150;
|
||||||
|
var $hight = 45;
|
||||||
|
var $size = 0; //this will be generated automaticlly - you can set it manually
|
||||||
|
var $scale = 0; //this will be generated automaticlly - you can set it manually
|
||||||
|
var $leng = 5;
|
||||||
|
var $noise_level = 0;
|
||||||
|
var $pixlized = 2;
|
||||||
|
var $angle = 20;
|
||||||
|
var $grid = true;
|
||||||
|
var $font;
|
||||||
|
var $drawcross = false;
|
||||||
|
var $drawcircles = true;
|
||||||
|
var $background = array(0, 0, 0); //rand if empty
|
||||||
|
var $text = array(255, 255, 255); //white
|
||||||
|
var $colortwo = array(204, 204, 204); //grey
|
||||||
|
var $postfield = 'panswer';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* called outside to genearate captcha
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function get_captcha()
|
||||||
|
{
|
||||||
|
if (@extension_loaded('gd') && function_exists('gd_info')) {
|
||||||
|
try {
|
||||||
|
$this->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 );}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/app/src/lib/arial.ttf
Normal file
BIN
backend/app/src/lib/arial.ttf
Normal file
Binary file not shown.
68
backend/app/src/scripts/auto-stop-containers.php
Executable file
68
backend/app/src/scripts/auto-stop-containers.php
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__ . '/../../vendor/autoload.php'; // Adjust path as needed
|
||||||
|
|
||||||
|
use App\Services\LxdService;
|
||||||
|
|
||||||
|
// Initialize LXD service
|
||||||
|
$lxdService = new LxdService();
|
||||||
|
|
||||||
|
// Define the directory containing access logs
|
||||||
|
$logDir = realpath(__DIR__ . '/../../public/last-access-logs'); // Adjust if you're in /app/src/Cron or similar
|
||||||
|
|
||||||
|
// Define the idle threshold in minutes
|
||||||
|
$thresholdMinutes = 30;
|
||||||
|
|
||||||
|
// Iterate over all log files in the specified directory
|
||||||
|
foreach (glob($logDir . '/*.txt') as $filePath) {
|
||||||
|
// Extract the container name from the file name
|
||||||
|
$containerName = basename($filePath, '.txt');
|
||||||
|
|
||||||
|
// Get the last line from the log file
|
||||||
|
$lastLine = getLastLine($filePath);
|
||||||
|
if (!$lastLine) {
|
||||||
|
echo "No access logs found for $containerName.\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the timestamp from the last log entry
|
||||||
|
$parts = explode(' : ', $lastLine);
|
||||||
|
if (!isset($parts[0])) continue;
|
||||||
|
|
||||||
|
$lastAccess = DateTime::createFromFormat('Y-m-d H:i:s', trim($parts[0]));
|
||||||
|
if (!$lastAccess) continue;
|
||||||
|
|
||||||
|
// Calculate the idle time in seconds
|
||||||
|
$now = new DateTime();
|
||||||
|
$interval = $now->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;
|
||||||
|
}
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -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
|
||||||
92
frontend/README.md
Normal file
92
frontend/README.md
Normal file
@ -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
|
||||||
6
frontend/app.vue
Normal file
6
frontend/app.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
9
frontend/assets/css/common.css
Normal file
9
frontend/assets/css/common.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
*{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body{
|
||||||
|
position: relative;
|
||||||
|
padding: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
6
frontend/comman-file.txt
Normal file
6
frontend/comman-file.txt
Normal file
@ -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
|
||||||
|
|
||||||
|
==========
|
||||||
130
frontend/components/Captcha.vue
Normal file
130
frontend/components/Captcha.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="captcha-wrapper">
|
||||||
|
<div class="captcha-container">
|
||||||
|
<input v-model="captchaInput" placeholder="Enter code" class="form-control"/>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-show="captchaLoaded"
|
||||||
|
:src="captchaUrl"
|
||||||
|
alt="CAPTCHA"
|
||||||
|
@load="onCaptchaLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FontAwesomeIcon icon="fa-solid fa-sync-alt" class="refresh-icon" @click="refreshCaptcha" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
|
||||||
|
// 👇 Use apiUrl from runtime config
|
||||||
|
const captchaUrl = ref(''); // initially empty
|
||||||
|
const captchaLoaded = ref(false);
|
||||||
|
const captchaInput = ref('');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCaptcha();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCaptcha = () => {
|
||||||
|
captchaLoaded.value = false;
|
||||||
|
captchaUrl.value = `${window.location.origin}/api/captcha?${Date.now()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshCaptcha = () => {
|
||||||
|
loadCaptcha();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCaptchaLoad = () => {
|
||||||
|
captchaLoaded.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit to parent whenever input changes
|
||||||
|
const emit = defineEmits(['update:captcha']);
|
||||||
|
|
||||||
|
watch(captchaInput, (newVal) => {
|
||||||
|
emit('update:captcha', newVal)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.captcha-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap:10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-container img {
|
||||||
|
max-height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
display: inline-block;
|
||||||
|
width: 50%;
|
||||||
|
flex:1;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #212529;
|
||||||
|
background-color: #fff;
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
color: #212529;
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #86b7fe;
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background-color: #93c5fd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
frontend/components/Spinner.vue
Normal file
46
frontend/components/Spinner.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="loader-overlay" v-if="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// Accept a `loading` prop to control visibility
|
||||||
|
defineProps({
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loader-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 5px solid #ccc;
|
||||||
|
border-top-color: #1e88e5; /* blue color */
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spin animation */
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
frontend/lxd-app.code-workspace
Normal file
7
frontend/lxd-app.code-workspace
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
94
frontend/nginx.conf
Normal file
94
frontend/nginx.conf
Normal file
@ -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);
|
||||||
|
}
|
||||||
16
frontend/nuxt.config.ts
Normal file
16
frontend/nuxt.config.ts
Normal file
@ -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
|
||||||
|
],
|
||||||
|
})
|
||||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
frontend/pages/index.vue
Normal file
55
frontend/pages/index.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<Spinner :loading="loading" />
|
||||||
|
<div v-html="output" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Spinner from '@/components/Spinner.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const output = ref('');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
// fetch without forcing responseType to text, so it defaults to JSON if possible
|
||||||
|
const res = await $fetch(`${config.public.apiUrl}/proxy`);
|
||||||
|
|
||||||
|
if (typeof res === 'object') {
|
||||||
|
if(res?.status === "not-found"){
|
||||||
|
router.push('/login');
|
||||||
|
}else{
|
||||||
|
// pretty print JSON response
|
||||||
|
output.value = `<pre>${JSON.stringify(res, null, 2)}</pre>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// treat as plain text or HTML
|
||||||
|
output.value = res;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
output.value = `<p style="color:red;">Container access failed.</p>`;
|
||||||
|
} 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: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
frontend/pages/login.vue
Normal file
130
frontend/pages/login.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<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>
|
||||||
10
frontend/plugins/fontawesome.client.ts
Normal file
10
frontend/plugins/fontawesome.client.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
frontend/public/robots.txt
Normal file
2
frontend/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
3
frontend/server/tsconfig.json
Normal file
3
frontend/server/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user