This commit is contained in:
corenthin 2025-12-12 14:49:32 +01:00
parent dc38b80db1
commit 2838b70a27
28 changed files with 1955 additions and 6 deletions

76
dist/config/index.js vendored Normal file
View File

@ -0,0 +1,76 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const dotenv_1 = __importDefault(require("dotenv"));
const zod_1 = require("zod");
dotenv_1.default.config();
const envSchema = zod_1.z.object({
PORT: zod_1.z.string().default('3000'),
DATABASE_URL: zod_1.z.string(),
JWT_SECRET: zod_1.z.string(),
JWT_TTL_SECONDS: zod_1.z.string().default('3600'),
APP_ADMIN_EMAIL: zod_1.z.string().optional(),
APP_ADMIN_PASSWORD: zod_1.z.string().optional(),
// Guacamole
GUACAMOLE_API_ENDPOINT: zod_1.z.string().default('https://guacamole.firewax.fr/guacamole/api'),
GUACAMOLE_USERNAME: zod_1.z.string().default('guacadmin'),
GUACAMOLE_PASSWORD: zod_1.z.string().default('guacadmin'),
// Terraform
TERRAFORM_DROP_DIR: zod_1.z.string().default('terraform_drop'),
TERRAFORM_TEMPLATE_PATH: zod_1.z.string().default('/home/corenthin/terraform'),
TERRAFORM_REMOTE_TEMPLATE_PATH: zod_1.z.string().default('/home/corenthin/terraform'),
TERRAFORM_DEPLOY_BASE_PATH: zod_1.z.string().default('/home/corenthin/'),
TERRAFORM_SSH_HOST: zod_1.z.string().default('127.0.0.1'),
TERRAFORM_SSH_USER: zod_1.z.string().default('corenthin'),
TERRAFORM_SSH_KEY_PATH: zod_1.z.string().default('/home/corenthin/.ssh/testapi'),
// Proxmox
PROXMOX_URL: zod_1.z.string().default('https://node1.solyone.fr:8006/api2/json'),
PROXMOX_TOKEN_ID: zod_1.z.string().default('terraform-prov@pve!mytoken'),
PROXMOX_TOKEN_SECRET: zod_1.z.string().default('a4b8720c-6e69-4309-ab56-54b62126b6e6'),
PROXMOX_INSECURE_TLS: zod_1.z.string().default('true'),
PROXMOX_TARGET_NODE: zod_1.z.string().default('node1'),
PROXMOX_MODEL_WINDOWS: zod_1.z.string().default('e1000'),
PROXMOX_MODEL_LINUX: zod_1.z.string().default('virtio'),
// Cors
CORS_ALLOWED_ORIGINS: zod_1.z.string().default('http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173,http://192.168.1.52:5173,https://dev.firewax.fr'),
});
const env = envSchema.parse(process.env);
exports.default = {
port: parseInt(env.PORT, 10),
jwt: {
secret: env.JWT_SECRET,
ttlSeconds: parseInt(env.JWT_TTL_SECONDS, 10),
},
admin: {
email: env.APP_ADMIN_EMAIL,
password: env.APP_ADMIN_PASSWORD,
},
guacamole: {
apiEndpoint: env.GUACAMOLE_API_ENDPOINT,
username: env.GUACAMOLE_USERNAME,
password: env.GUACAMOLE_PASSWORD,
},
terraform: {
dropDir: env.TERRAFORM_DROP_DIR,
templatePath: env.TERRAFORM_TEMPLATE_PATH,
remoteTemplatePath: env.TERRAFORM_REMOTE_TEMPLATE_PATH,
deployBasePath: env.TERRAFORM_DEPLOY_BASE_PATH,
sshHost: env.TERRAFORM_SSH_HOST,
sshUser: env.TERRAFORM_SSH_USER,
sshKeyPath: env.TERRAFORM_SSH_KEY_PATH,
},
proxmox: {
url: env.PROXMOX_URL,
tokenId: env.PROXMOX_TOKEN_ID,
tokenSecret: env.PROXMOX_TOKEN_SECRET,
insecureTls: env.PROXMOX_INSECURE_TLS === 'true',
targetNode: env.PROXMOX_TARGET_NODE,
modelWindows: env.PROXMOX_MODEL_WINDOWS,
modelLinux: env.PROXMOX_MODEL_LINUX,
},
cors: {
allowedOrigins: env.CORS_ALLOWED_ORIGINS.split(','),
}
};

218
dist/controllers/adminController.js vendored Normal file
View File

@ -0,0 +1,218 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.deleteUser = exports.updateUser = exports.createUser = exports.listUsers = exports.getDashboard = void 0;
const zod_1 = require("zod");
const uuid_1 = require("uuid");
const prisma_1 = __importDefault(require("../utils/prisma"));
const authService = __importStar(require("../services/authService"));
const config_1 = __importDefault(require("../config"));
const logger_1 = __importDefault(require("../utils/logger"));
const createUserSchema = zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string().min(8),
displayName: zod_1.z.string().optional(),
isAdmin: zod_1.z.boolean().default(false),
});
const updateUserSchema = zod_1.z.object({
email: zod_1.z.string().email().optional(),
password: zod_1.z.string().min(8).optional(),
displayName: zod_1.z.string().optional().nullable(),
isAdmin: zod_1.z.boolean().optional(),
});
const mapUser = (user) => ({
id: user.id,
email: user.email,
name: user.display_name || user.email,
displayName: user.display_name,
isAdmin: user.is_admin,
});
const getDashboard = async (req, res) => {
try {
const users = await prisma_1.default.users.findMany();
const summaries = users.map(mapUser);
const currentUser = await prisma_1.default.users.findUnique({ where: { id: req.user.id } });
res.json({
currentUser: mapUser(currentUser),
users: summaries,
});
}
catch (error) {
logger_1.default.error(`Dashboard error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.getDashboard = getDashboard;
const listUsers = async (req, res) => {
try {
const users = await prisma_1.default.users.findMany();
const summaries = users.map(mapUser);
res.json(summaries);
}
catch (error) {
logger_1.default.error(`List users error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.listUsers = listUsers;
const createUser = async (req, res) => {
try {
const { email, password, displayName, isAdmin } = createUserSchema.parse(req.body);
// Validate email domain
if (!email.endsWith('@solyti.fr')) {
res.status(400).json({ message: 'Email must belong to the @solyti.fr domain' });
return;
}
const existingUser = await prisma_1.default.users.findUnique({ where: { email } });
if (existingUser) {
res.status(409).json({ message: 'Email already registered' });
return;
}
const passwordHash = await authService.hashPassword(password);
const newUser = await prisma_1.default.users.create({
data: {
id: (0, uuid_1.v4)(),
email,
password_hash: passwordHash,
display_name: displayName,
is_admin: isAdmin,
},
});
res.status(201).json(mapUser(newUser));
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
logger_1.default.error(`Create user error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.createUser = createUser;
const updateUser = async (req, res) => {
try {
const { id } = req.params;
const { email, password, displayName, isAdmin } = updateUserSchema.parse(req.body);
const currentUser = req.user;
const userToUpdate = await prisma_1.default.users.findUnique({ where: { id } });
if (!userToUpdate) {
res.status(404).json({ message: 'User not found' });
return;
}
const defaultAdminEmail = config_1.default.admin.email;
// Protect user if they match the configured admin email OR if their name is 'Corenthin'
const isTargetDefaultAdmin = (defaultAdminEmail && userToUpdate.email.toLowerCase() === defaultAdminEmail.toLowerCase()) ||
userToUpdate.display_name === 'Corenthin';
// Prevent self-demotion for ANY user
if (currentUser.id === userToUpdate.id && isAdmin === false) {
res.status(400).json({ message: 'You cannot remove your own admin rights' });
return;
}
if (isTargetDefaultAdmin && currentUser.id !== userToUpdate.id) {
res.status(403).json({ message: 'Default administrator can only modify their own profile' });
return;
}
if (isTargetDefaultAdmin && email && defaultAdminEmail && email.toLowerCase() !== defaultAdminEmail.toLowerCase()) {
res.status(400).json({ message: 'Default administrator email cannot change' });
return;
}
if (isTargetDefaultAdmin && isAdmin !== undefined && isAdmin !== userToUpdate.is_admin) {
res.status(400).json({ message: 'Default administrator rights cannot change' });
return;
}
const updateData = {};
if (email)
updateData.email = email;
if (password)
updateData.password_hash = await authService.hashPassword(password);
if (displayName !== undefined)
updateData.display_name = displayName; // Allow null to clear
if (isAdmin !== undefined)
updateData.is_admin = isAdmin;
const updatedUser = await prisma_1.default.users.update({
where: { id },
data: updateData,
});
res.json(mapUser(updatedUser));
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
// Handle unique constraint violation for email
// Prisma throws P2002
if (error.code === 'P2002') {
res.status(409).json({ message: 'Email already registered' });
return;
}
logger_1.default.error(`Update user error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.updateUser = updateUser;
const deleteUser = async (req, res) => {
try {
const { id } = req.params;
const currentUser = req.user;
if (currentUser.id === id) {
res.status(400).json({ message: 'Cannot delete yourself' });
return;
}
const userToDelete = await prisma_1.default.users.findUnique({ where: { id } });
if (!userToDelete) {
res.status(404).json({ message: 'User not found' });
return;
}
const defaultAdminEmail = config_1.default.admin.email;
const isTargetDefaultAdmin = (defaultAdminEmail && userToDelete.email.toLowerCase() === defaultAdminEmail.toLowerCase()) ||
userToDelete.display_name === 'Corenthin';
if (isTargetDefaultAdmin) {
res.status(403).json({ message: 'Default administrator cannot be deleted' });
return;
}
await prisma_1.default.users.delete({ where: { id } });
res.status(204).send();
}
catch (error) {
logger_1.default.error(`Delete user error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.deleteUser = deleteUser;

127
dist/controllers/authController.js vendored Normal file
View File

@ -0,0 +1,127 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.logout = exports.me = exports.login = void 0;
const zod_1 = require("zod");
const prisma_1 = __importDefault(require("../utils/prisma"));
const authService = __importStar(require("../services/authService"));
const bruteforceService_1 = __importDefault(require("../services/bruteforceService"));
const helpers_1 = require("../utils/helpers");
const logger_1 = __importDefault(require("../utils/logger"));
const loginSchema = zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string(),
});
const login = async (req, res) => {
try {
const { email, password } = loginSchema.parse(req.body);
if (bruteforceService_1.default.isLocked(email)) {
res.status(429).json({ message: 'Too many attempts, temporarily locked' });
return;
}
const user = await prisma_1.default.users.findUnique({ where: { email } });
if (!user) {
bruteforceService_1.default.recordFailure(email);
res.status(401).json({ message: 'Invalid credentials' });
return;
}
const isValid = await authService.verifyPassword(password, user.password_hash);
if (!isValid) {
bruteforceService_1.default.recordFailure(email);
res.status(401).json({ message: 'Invalid credentials' });
return;
}
bruteforceService_1.default.recordSuccess(email);
const token = authService.signToken(user.id, user.email);
(0, helpers_1.setSessionCookie)(res, token);
const meUser = {
id: user.id,
email: user.email,
name: user.display_name || user.email,
displayName: user.display_name,
isAdmin: user.is_admin,
};
res.json({ token, user: meUser });
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
logger_1.default.error(`Login error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.login = login;
const me = async (req, res) => {
try {
const token = req.cookies.token || req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ message: 'Unauthenticated' });
return;
}
const claims = authService.verifyToken(token);
if (!claims) {
res.status(401).json({ message: 'Invalid session' });
return;
}
const user = await prisma_1.default.users.findUnique({ where: { id: claims.userId } });
if (!user || user.email !== claims.email) {
res.status(401).json({ message: 'Invalid session' });
return;
}
const meUser = {
id: user.id,
email: user.email,
name: user.display_name || user.email,
displayName: user.display_name,
isAdmin: user.is_admin,
};
res.json({ user: meUser });
}
catch (error) {
logger_1.default.error(`Me error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.me = me;
const logout = async (req, res) => {
(0, helpers_1.clearSessionCookie)(res);
res.status(204).send();
};
exports.logout = logout;

View File

@ -0,0 +1,93 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSchema = exports.createDeployment = void 0;
const zod_1 = require("zod");
const uuid_1 = require("uuid");
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const config_1 = __importDefault(require("../config"));
const tfvarsService = __importStar(require("../services/tfvarsService"));
const zod_to_json_schema_1 = require("zod-to-json-schema");
const logger_1 = __importDefault(require("../utils/logger"));
const deploymentSchema = zod_1.z.object({
name: zod_1.z.string().min(1),
userCount: zod_1.z.number().int().min(1),
vmTemplate: zod_1.z.string(),
cpu: zod_1.z.number().int().min(1),
ramMb: zod_1.z.number().int().min(256),
diskGb: zod_1.z.number().int().min(10),
ipStartHost: zod_1.z.number().int().min(2).max(254).optional(),
});
const createDeployment = async (req, res) => {
try {
const payload = deploymentSchema.parse(req.body);
if (payload.ipStartHost) {
const last = payload.ipStartHost + payload.userCount - 1;
if (last > 254) {
res.status(400).json({ error: 'ip range exceeds 192.168.143.254' });
return;
}
}
const id = (0, uuid_1.v4)();
const tfvarsContent = tfvarsService.generate(payload);
const dropDir = config_1.default.terraform.dropDir;
const filename = `${payload.name.replace(/ /g, '_')}.tfvars`;
const filePath = path_1.default.join(dropDir, filename);
await promises_1.default.mkdir(dropDir, { recursive: true });
await promises_1.default.writeFile(filePath, tfvarsContent);
res.json({
id,
tfvarsPath: filePath,
});
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
res.status(400).json({ error: 'Invalid input', details: error.issues });
return;
}
logger_1.default.error(`Create deployment error: ${error}`);
res.status(500).json({ error: 'Internal server error' });
}
};
exports.createDeployment = createDeployment;
const getSchema = async (req, res) => {
const schema = (0, zod_to_json_schema_1.zodToJsonSchema)(deploymentSchema, 'DeploymentRequest');
res.json(schema);
};
exports.getSchema = getSchema;

170
dist/controllers/terraformController.js vendored Normal file
View File

@ -0,0 +1,170 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.destroyFormation = exports.listFormations = exports.getTemplates = exports.apply = void 0;
const zod_1 = require("zod");
const prisma_1 = __importDefault(require("../utils/prisma"));
const config_1 = __importDefault(require("../config"));
const terraformService = __importStar(require("../services/terraformService"));
const proxmoxService = __importStar(require("../services/proxmoxService"));
const path_1 = __importDefault(require("path"));
const logger_1 = __importDefault(require("../utils/logger"));
// Zod schemas
const vmSchema = zod_1.z.object({
name: zod_1.z.string(),
vmid: zod_1.z.number(),
cores: zod_1.z.number(),
memory: zod_1.z.number(),
disk_size: zod_1.z.number(),
template: zod_1.z.string(),
ip: zod_1.z.string(),
guacamoleIp: zod_1.z.string().optional(),
bridge: zod_1.z.string().optional(),
model: zod_1.z.enum(['windows', 'linux']).default('linux'),
rdpDomain: zod_1.z.string().optional(),
rdpPort: zod_1.z.number().optional(),
});
const guacamoleUserSchema = zod_1.z.object({
username: zod_1.z.string(),
password: zod_1.z.string(),
});
const guacamoleRequestSchema = zod_1.z.object({
groupName: zod_1.z.string().optional(),
users: zod_1.z.array(guacamoleUserSchema),
});
const applySchema = zod_1.z.object({
formationId: zod_1.z.string().min(1),
vms: zod_1.z.array(vmSchema).min(1),
guacamole: guacamoleRequestSchema.optional(),
});
// Helper to clean formation ID
const cleanFormationId = (id) => {
return id;
};
const isValidFormationId = (id) => {
return /^[a-zA-Z0-9-_]+$/.test(id);
};
const apply = async (req, res) => {
try {
const body = applySchema.parse(req.body);
let formationId = cleanFormationId(body.formationId);
if (!isValidFormationId(formationId)) {
res.status(400).json({ message: 'Invalid formation id' });
return;
}
const existingFormation = await prisma_1.default.terraform_formations.findUnique({
where: { formation_id: formationId }
});
if (existingFormation) {
res.status(409).json({ message: 'Formation already exists' });
return;
}
// Spawn background job
terraformService.handleApplyJob(formationId, body).catch(err => {
logger_1.default.error(`Apply job failed for ${formationId}: ${err}`);
});
const remoteDirPreview = path_1.default.join(config_1.default.terraform.deployBasePath, formationId);
res.status(202).json({
status: 'queued',
formationId,
remotePath: remoteDirPreview,
});
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
logger_1.default.warn(`Validation failed: ${JSON.stringify(error.issues)}`);
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
logger_1.default.error(`Apply error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.apply = apply;
const getTemplates = async (req, res) => {
try {
const templates = await proxmoxService.fetchTemplates();
res.json({ templates });
}
catch (error) {
logger_1.default.error(`Get templates error: ${error}`);
res.status(502).json({ message: 'Failed to fetch templates from proxmox' });
}
};
exports.getTemplates = getTemplates;
const listFormations = async (req, res) => {
try {
const formationsRaw = await prisma_1.default.terraform_formations.findMany();
const formations = formationsRaw.map(f => ({
id: f.id,
formationId: f.formation_id,
remotePath: f.remote_path,
guacamoleGroupName: f.guacamole_group_name,
createdAt: f.created_at,
status: 'active',
}));
res.json({ formations });
}
catch (error) {
logger_1.default.error(`List formations error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.listFormations = listFormations;
const destroyFormation = async (req, res) => {
try {
const { formationId } = req.params;
const formation = await prisma_1.default.terraform_formations.findUnique({
where: { formation_id: formationId }
});
if (!formation) {
res.status(404).json({ message: 'Formation not found' });
return;
}
// Run in background
terraformService.destroyFormation(formationId).catch(err => {
logger_1.default.error(`Background destroy failed for ${formationId}: ${err}`);
});
res.status(202).json({ message: 'Formation destruction queued' });
}
catch (error) {
logger_1.default.error(`Destroy formation error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.destroyFormation = destroyFormation;

70
dist/index.js vendored Normal file
View File

@ -0,0 +1,70 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const helmet_1 = __importDefault(require("helmet"));
const cookie_parser_1 = __importDefault(require("cookie-parser"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const morgan_1 = __importDefault(require("morgan"));
const config_1 = __importDefault(require("./config"));
const routes_1 = __importDefault(require("./routes"));
const logger_1 = __importDefault(require("./utils/logger"));
const errorHandler_1 = require("./middlewares/errorHandler");
const prisma_1 = __importDefault(require("./utils/prisma"));
const app = (0, express_1.default)();
// Trust the first proxy (Nginx/Traefik)
app.set('trust proxy', 1);
// HTTP Request Logging
app.use((0, morgan_1.default)('combined', { stream: { write: (message) => logger_1.default.http(message.trim()) } }));
app.use((0, helmet_1.default)());
app.use((0, cookie_parser_1.default)());
app.use(express_1.default.json());
// Rate Limiting
const limiter = (0, express_rate_limit_1.default)({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use(limiter);
app.use((0, cors_1.default)({
origin: (origin, callback) => {
if (!origin || config_1.default.cors.allowedOrigins.includes(origin)) {
callback(null, true);
}
else {
logger_1.default.warn(`CORS blocked origin: ${origin}`);
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
}));
app.use('/api', routes_1.default);
// Error Handling Middleware (must be last)
app.use(errorHandler_1.errorHandler);
const server = app.listen(config_1.default.port, () => {
logger_1.default.info(`Server running on port ${config_1.default.port}`);
logger_1.default.info(`CORS Allowed Origins: ${config_1.default.cors.allowedOrigins.join(', ')}`);
});
// Graceful Shutdown
const gracefulShutdown = async () => {
logger_1.default.info('Received kill signal, shutting down gracefully');
server.close(() => {
logger_1.default.info('Closed out remaining connections');
prisma_1.default.$disconnect().then(() => {
logger_1.default.info('Database connection closed');
process.exit(0);
});
});
setTimeout(() => {
logger_1.default.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

79
dist/middlewares/authMiddleware.js vendored Normal file
View File

@ -0,0 +1,79 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.requireAdmin = exports.authenticate = void 0;
const authService = __importStar(require("../services/authService"));
const prisma_1 = __importDefault(require("../utils/prisma"));
const authenticate = async (req, res, next) => {
const token = req.cookies.token || req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ message: 'Unauthenticated' });
return;
}
const claims = authService.verifyToken(token);
if (!claims) {
res.status(401).json({ message: 'Invalid session' });
return;
}
try {
const user = await prisma_1.default.users.findUnique({ where: { id: claims.userId } });
if (!user || user.email !== claims.email) {
res.status(401).json({ message: 'Invalid session' });
return;
}
req.user = {
id: user.id,
email: user.email,
isAdmin: user.is_admin,
};
next();
}
catch (error) {
console.error('Auth middleware error:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
exports.authenticate = authenticate;
const requireAdmin = (req, res, next) => {
if (!req.user || !req.user.isAdmin) {
res.status(403).json({ message: 'Admin privileges required' });
return;
}
next();
};
exports.requireAdmin = requireAdmin;

25
dist/middlewares/errorHandler.js vendored Normal file
View File

@ -0,0 +1,25 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.errorHandler = void 0;
const logger_1 = __importDefault(require("../utils/logger"));
const zod_1 = require("zod");
const errorHandler = (err, req, res, next) => {
logger_1.default.error(`${err.status || 500} - ${err.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`);
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({
message: 'Invalid input',
errors: err.issues,
});
return;
}
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
};
exports.errorHandler = errorHandler;

46
dist/routes/admin.js vendored Normal file
View File

@ -0,0 +1,46 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const adminController = __importStar(require("../controllers/adminController"));
const authMiddleware_1 = require("../middlewares/authMiddleware");
const router = (0, express_1.Router)();
router.use(authMiddleware_1.authenticate, authMiddleware_1.requireAdmin);
router.get('/', adminController.getDashboard);
router.get('/users', adminController.listUsers);
router.post('/users', adminController.createUser);
router.put('/users/:id', adminController.updateUser);
router.delete('/users/:id', adminController.deleteUser);
exports.default = router;

42
dist/routes/auth.js vendored Normal file
View File

@ -0,0 +1,42 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const authController = __importStar(require("../controllers/authController"));
const router = (0, express_1.Router)();
router.post('/login', authController.login);
router.get('/me', authController.me);
router.post('/logout', authController.logout);
exports.default = router;

41
dist/routes/deployments.js vendored Normal file
View File

@ -0,0 +1,41 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const deploymentsController = __importStar(require("../controllers/deploymentsController"));
const router = (0, express_1.Router)();
router.post('/', deploymentsController.createDeployment);
router.get('/schema', deploymentsController.getSchema);
exports.default = router;

28
dist/routes/index.js vendored Normal file
View File

@ -0,0 +1,28 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const auth_1 = __importDefault(require("./auth"));
const deployments_1 = __importDefault(require("./deployments"));
const admin_1 = __importDefault(require("./admin"));
const terraform_1 = __importDefault(require("./terraform"));
const router = (0, express_1.Router)();
const prisma_1 = __importDefault(require("../utils/prisma"));
const logger_1 = __importDefault(require("../utils/logger"));
router.get('/healthz', async (req, res) => {
try {
await prisma_1.default.$queryRaw `SELECT 1`;
res.status(200).json({ status: 'ok', database: 'connected' });
}
catch (error) {
logger_1.default.error(`Health check failed: ${error}`);
res.status(503).json({ status: 'error', database: 'disconnected' });
}
});
router.use('/auth', auth_1.default);
router.use('/deployments', deployments_1.default);
router.use('/admin', admin_1.default);
router.use('/terraform', terraform_1.default);
exports.default = router;

43
dist/routes/terraform.js vendored Normal file
View File

@ -0,0 +1,43 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const terraformController = __importStar(require("../controllers/terraformController"));
const router = (0, express_1.Router)();
router.post('/apply', terraformController.apply);
router.get('/templates', terraformController.getTemplates);
router.get('/formations', terraformController.listFormations);
router.delete('/formations/:formationId', terraformController.destroyFormation);
exports.default = router;

39
dist/services/authService.js vendored Normal file
View File

@ -0,0 +1,39 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = void 0;
const argon2_1 = __importDefault(require("argon2"));
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const config_1 = __importDefault(require("../config"));
const hashPassword = async (password) => {
return await argon2_1.default.hash(password);
};
exports.hashPassword = hashPassword;
const verifyPassword = async (password, hash) => {
try {
return await argon2_1.default.verify(hash, password);
}
catch (err) {
return false;
}
};
exports.verifyPassword = verifyPassword;
const signToken = (userId, email) => {
return jsonwebtoken_1.default.sign({ sub: userId, email }, config_1.default.jwt.secret, { expiresIn: config_1.default.jwt.ttlSeconds });
};
exports.signToken = signToken;
const verifyToken = (token) => {
try {
const decoded = jsonwebtoken_1.default.verify(token, config_1.default.jwt.secret);
return {
userId: decoded.sub,
email: decoded.email,
};
}
catch (err) {
return null;
}
};
exports.verifyToken = verifyToken;

34
dist/services/bruteforceService.js vendored Normal file
View File

@ -0,0 +1,34 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class AuthGuard {
constructor() {
this.attempts = new Map();
}
isLocked(key) {
const attempt = this.attempts.get(key);
if (attempt && attempt.lockUntil) {
return Date.now() < attempt.lockUntil;
}
return false;
}
recordFailure(key, maxAttempts = 5, lockoutSecs = 300) {
let attempt = this.attempts.get(key);
const now = Date.now();
if (!attempt) {
attempt = { count: 0, lockUntil: null };
this.attempts.set(key, attempt);
}
if (attempt.lockUntil && now < attempt.lockUntil) {
return;
}
attempt.count += 1;
if (attempt.count >= maxAttempts) {
attempt.lockUntil = now + (lockoutSecs * 1000);
attempt.count = 0; // Reset count after lock
}
}
recordSuccess(key) {
this.attempts.delete(key);
}
}
exports.default = new AuthGuard();

90
dist/services/guacamoleService.js vendored Normal file
View File

@ -0,0 +1,90 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.deleteProvisioning = exports.provision = void 0;
const guacamoleClient_1 = require("../utils/guacamoleClient");
const logger_1 = __importDefault(require("../utils/logger"));
// Helper to extract numeric suffix for matching
const extractNumericSuffix = (s) => {
const match = s.match(/(\d+)$/);
return match ? parseInt(match[1], 10) : null;
};
const matchUserForVm = (vm, users) => {
const vmKey = extractNumericSuffix(vm.name);
if (vmKey === null)
return undefined;
return users.find(user => {
const userKey = extractNumericSuffix(user.username);
return userKey === vmKey;
});
};
const provision = async (formationId, groupName, users, vms) => {
if (users.length === 0)
return;
const session = await guacamoleClient_1.GuacSession.login();
let groupIdentifier = groupName || `formation-${formationId}`;
// Logic to clean formation ID if groupName is missing
if (!groupName) {
groupIdentifier = formationId; // Simplified
}
await session.checkExistence(groupIdentifier, users, vms);
await session.ensureUserGroup(groupIdentifier, groupIdentifier);
// Create users in parallel
await Promise.all(users.map(user => session.createOrUpdateUser(user)));
// Create connections in parallel
const connectionPromises = vms
.filter(vm => vm.ip)
.map(async (vm) => {
const id = await session.createOrUpdateConnection(groupIdentifier, vm);
return { id, vm };
});
const connections = await Promise.all(connectionPromises);
await session.setGroupMembers(groupIdentifier, users);
// Grant permissions in parallel
const permissionPromises = connections.map(({ id, vm }) => {
const user = matchUserForVm(vm, users);
if (user) {
return session.grantUserConnection(user.username, id);
}
else {
return session.grantGroupConnection(groupIdentifier, id);
}
});
await Promise.all(permissionPromises);
};
exports.provision = provision;
const deleteProvisioning = async (formationId, groupName) => {
const session = await guacamoleClient_1.GuacSession.login();
const groupIdentifier = groupName || `formation-${formationId}`;
try {
logger_1.default.info(`Cleaning up Guacamole for group ${groupIdentifier}`);
const { users, connections } = await session.getGroupMembers(groupIdentifier);
logger_1.default.info(`Found ${users.length} users and ${connections.length} connections to delete for group ${groupIdentifier}`);
// Delete users in parallel
await Promise.all(users.map(async (user) => {
try {
await session.deleteUser(user);
}
catch (e) { }
}));
// Delete connections in parallel
await Promise.all(connections.map(async (conn) => {
try {
await session.deleteConnection(conn);
}
catch (e) { }
}));
try {
await session.deleteGroup(groupIdentifier);
}
catch (e) { }
logger_1.default.info(`Guacamole cleanup completed for ${groupIdentifier}`);
}
catch (error) {
logger_1.default.warn(`Error during Guacamole cleanup for ${groupIdentifier}: ${error.message}`);
// If group doesn't exist, we might still want to try other cleanups if we implemented them
}
};
exports.deleteProvisioning = deleteProvisioning;

37
dist/services/proxmoxService.js vendored Normal file
View File

@ -0,0 +1,37 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchTemplates = void 0;
const axios_1 = __importDefault(require("axios"));
const https_1 = __importDefault(require("https"));
const config_1 = __importDefault(require("../config"));
const fetchTemplates = async () => {
const { url, targetNode, tokenId, tokenSecret, insecureTls } = config_1.default.proxmox;
const apiUrl = `${url.replace(/\/$/, '')}/nodes/${targetNode}/qemu?full=1`;
const agent = new https_1.default.Agent({
rejectUnauthorized: !insecureTls
});
try {
const response = await axios_1.default.get(apiUrl, {
headers: {
'Authorization': `PVEAPIToken=${tokenId.trim()}=${tokenSecret.trim()}`
},
httpsAgent: agent
});
const templates = response.data.data
.filter(vm => vm.template === 1 && vm.vmid)
.map(vm => ({
vmid: vm.vmid,
name: vm.name || `vm-${vm.vmid}`,
diskSizeGb: vm.maxdisk ? Math.floor(vm.maxdisk / 1073741824) : 0
}));
return templates;
}
catch (error) {
console.error('Proxmox fetch templates error:', error);
throw new Error('Failed to fetch templates from Proxmox');
}
};
exports.fetchTemplates = fetchTemplates;

163
dist/services/terraformService.js vendored Normal file
View File

@ -0,0 +1,163 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.destroyFormation = exports.handleApplyJob = void 0;
const uuid_1 = require("uuid");
const path_1 = __importDefault(require("path"));
const prisma_1 = __importDefault(require("../utils/prisma"));
const config_1 = __importDefault(require("../config"));
const tfvarsService = __importStar(require("./tfvarsService"));
const shell = __importStar(require("../utils/shell"));
const guacamoleService = __importStar(require("./guacamoleService"));
const logger_1 = __importDefault(require("../utils/logger"));
const prepareRemoteWorkspace = async (formationId) => {
const remoteDir = path_1.default.posix.join(config_1.default.terraform.deployBasePath, formationId);
await ensureRemoteDirectory(remoteDir);
await copyTemplateToRemote(remoteDir);
return remoteDir;
};
const ensureRemoteDirectory = async (remoteDir) => {
// Check if exists and cleanup
const checkCmd = `test -d ${shell.escapeShell(remoteDir)}`;
const checkRes = await shell.executeSsh(checkCmd);
if (checkRes.code === 0) {
logger_1.default.warn(`Remote directory ${remoteDir} exists. Cleaning up before copy.`);
await cleanupRemote(remoteDir);
}
};
const copyTemplateToRemote = async (remoteDir) => {
const templateSrc = config_1.default.terraform.remoteTemplatePath;
// Copy template using remote cp -r
// This assumes templateSrc exists on the remote machine
const copyCmd = `cp -r ${shell.escapeShell(templateSrc)} ${shell.escapeShell(remoteDir)}`;
const copyRes = await shell.executeSsh(copyCmd);
if (copyRes.code !== 0) {
throw new Error(`Remote copy failed: ${copyRes.stderr}`);
}
};
const cleanupRemote = async (remoteDir) => {
const cmd = `rm -rf ${shell.escapeShell(remoteDir)}`;
await shell.executeSsh(cmd);
};
const runTerraformCommand = async (remoteDir, command) => {
const fullCmd = `cd ${shell.escapeShell(remoteDir)} && terraform ${command}`;
const res = await shell.executeSsh(fullCmd);
if (res.code !== 0) {
throw new Error(`Terraform command '${command}' failed: ${res.stderr}`);
}
return res;
};
const handleApplyJob = async (formationId, req) => {
const remoteDir = await prepareRemoteWorkspace(formationId);
try {
const tfvars = tfvarsService.generateVmDefinitions(req);
// Upload tfvars
// We use path.posix.join to ensure forward slashes for remote linux path
const uploadRes = await shell.uploadFileSsh(path_1.default.posix.join(remoteDir, 'terraform.tfvars'), tfvars);
if (uploadRes.code !== 0) {
throw new Error(`Upload tfvars failed: ${uploadRes.stderr}`);
}
// Terraform init
await runTerraformCommand(remoteDir, 'init -input=false -upgrade');
// Prepare Terraform Apply Promise
const terraformApplyPromise = runTerraformCommand(remoteDir, 'apply -auto-approve -input=false');
// Prepare Guacamole Provisioning Promise
let guacamolePromise = Promise.resolve();
if (req.guacamole) {
logger_1.default.info(`Starting Guacamole provisioning for formation ${formationId}`);
guacamolePromise = guacamoleService.provision(formationId, req.guacamole.groupName, req.guacamole.users, req.vms.map(vm => ({
name: vm.name,
ip: vm.guacamoleIp || vm.ip,
rdpDomain: vm.rdpDomain,
rdpPort: vm.rdpPort,
}))).then(() => {
logger_1.default.info(`Guacamole provisioning succeeded for formation ${formationId}`);
}).catch((error) => {
logger_1.default.error(`Guacamole provisioning failed for formation ${formationId}: ${error.message}`);
if (error.config)
logger_1.default.error(`Failed request: ${error.config.method?.toUpperCase()} ${error.config.url}`);
if (error.response)
logger_1.default.error(`Response status: ${error.response.status}`);
// We might want to re-throw or handle this differently depending on if we want the whole job to fail
throw error;
});
}
// Run both in parallel
await Promise.all([terraformApplyPromise, guacamolePromise]);
// Save to DB
await prisma_1.default.terraform_formations.create({
data: {
id: (0, uuid_1.v4)(),
formation_id: formationId,
remote_path: remoteDir,
guacamole_group_name: req.guacamole?.groupName,
}
});
}
catch (error) {
// Cleanup on failure if it was created during this job
await cleanupRemote(remoteDir);
throw error;
}
};
exports.handleApplyJob = handleApplyJob;
const destroyFormation = async (formationId) => {
const formation = await prisma_1.default.terraform_formations.findUnique({
where: { formation_id: formationId }
});
if (!formation) {
throw new Error('Formation not found');
}
const remoteDir = formation.remote_path;
// Terraform destroy
// We run init first to be safe, though state should be there
await runTerraformCommand(remoteDir, 'init -input=false');
await runTerraformCommand(remoteDir, 'destroy -auto-approve -input=false -lock=false');
// Remove remote dir
await cleanupRemote(remoteDir);
// Delete from DB
await prisma_1.default.terraform_formations.delete({
where: { id: formation.id }
});
// Cleanup Guacamole
if (formation.guacamole_group_name || formation.formation_id) {
guacamoleService.deleteProvisioning(formation.formation_id, formation.guacamole_group_name || undefined)
.catch(err => logger_1.default.error(`Guacamole cleanup failed for ${formation.formation_id}: ${err}`));
}
};
exports.destroyFormation = destroyFormation;

76
dist/services/tfvarsService.js vendored Normal file
View File

@ -0,0 +1,76 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateVmDefinitions = exports.generate = void 0;
const config_1 = __importDefault(require("../config"));
const escape = (s) => s.replace(/"/g, '\\"');
const generate = (req) => {
const basePrefix = '192.168.143';
const start = req.ipStartHost || 50;
const ips = Array.from({ length: req.userCount }, (_, i) => `${basePrefix}.${start + i}`);
let buf = '';
buf += `deployment_name = "${escape(req.name)}"\n`;
buf += `instance_count = ${req.userCount}\n`;
buf += `vm_template = "${escape(req.vmTemplate)}"\n`;
buf += `cpu = ${req.cpu}\n`;
buf += `ram_mb = ${req.ramMb}\n`;
buf += `disk_gb = ${req.diskGb}\n`;
buf += `network_cidr = "192.168.143.0/24"\n`;
const ipsStr = ips.map(ip => `"${escape(ip)}"`).join(', ');
buf += `ips = [${ipsStr}]\n`;
return buf;
};
exports.generate = generate;
const generateVmDefinitions = (req) => {
const cfg = config_1.default.proxmox;
let buf = '';
buf += `proxmox_url = "${escape(cfg.url)}"\n`;
buf += `proxmox_token_id = "${escape(cfg.tokenId)}"\n`;
buf += `proxmox_token_secret = "${escape(cfg.tokenSecret)}"\n`;
buf += `proxmox_insecure_tls = ${cfg.insecureTls}\n`;
buf += `target_node = "${escape(cfg.targetNode)}"\n`;
buf += `model = {\n`;
buf += ` "windows" = "${escape(cfg.modelWindows)}"\n`;
buf += ` "linux" = "${escape(cfg.modelLinux)}"\n`;
buf += `}\n\n`;
buf += `vms = {\n`;
for (const vm of req.vms) {
buf += generateVmBlock(vm, cfg);
}
buf += `}\n`;
return buf;
};
exports.generateVmDefinitions = generateVmDefinitions;
const generateVmBlock = (vm, cfg) => {
let buf = ` "${escape(vm.name)}" = {\n`;
buf += ` vmid = ${vm.vmid}\n`;
buf += ` cores = ${vm.cores}\n`;
buf += ` memory = ${vm.memory}\n`;
buf += ` disk_size = "${vm.disk_size}G"\n`;
buf += ` template = "${escape(vm.template)}"\n`;
buf += ` full_clone = false\n`;
buf += generateNetworkInterfaceBlock(vm, cfg);
buf += ` }\n`;
return buf;
};
const generateNetworkInterfaceBlock = (vm, cfg) => {
const gateway = "192.168.143.254";
const bridge = vm.bridge || "GUACALAN";
// Check if model is explicitly windows OR if template name suggests windows
const isWindows = (vm.model && vm.model.toLowerCase() === 'windows') ||
(vm.template && /w(?:in|10|11|2019|2022)/i.test(vm.template));
const modelValue = isWindows ? cfg.modelWindows : cfg.modelLinux;
let buf = ` network_interfaces = [\n`;
buf += ` {\n`;
buf += ` ip = "${escape(vm.ip)}"\n`;
if (vm.ip.toLowerCase() !== 'dhcp') {
buf += ` gateway = "${escape(gateway)}"\n`;
}
buf += ` bridge = "${escape(bridge)}"\n`;
buf += ` model = "${escape(modelValue)}"\n`;
buf += ` }\n`;
buf += ` ]\n`;
return buf;
};

2
dist/types/terraform.js vendored Normal file
View File

@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

263
dist/utils/guacamoleClient.js vendored Normal file
View File

@ -0,0 +1,263 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.GuacSession = void 0;
const axios_1 = __importDefault(require("axios"));
const https_1 = __importDefault(require("https"));
const config_1 = __importDefault(require("../config"));
const DEFAULT_RDP_PORT = 3389;
class GuacSession {
constructor(client, base, dataSource, token) {
this.client = client;
this.base = base;
this.dataSource = dataSource;
this.token = token;
}
static async login() {
const { apiEndpoint, username, password } = config_1.default.guacamole;
const base = apiEndpoint.replace(/\/$/, '');
const agent = new https_1.default.Agent({
rejectUnauthorized: false
});
const client = axios_1.default.create({
httpsAgent: agent,
timeout: 20000
});
const params = new URLSearchParams();
params.append('username', username);
params.append('password', password);
try {
const response = await client.post(`${base}/tokens`, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const tokenResponse = response.data;
let dataSource = tokenResponse.dataSource;
if (!dataSource && tokenResponse.availableDataSources && tokenResponse.availableDataSources.length > 0) {
dataSource = tokenResponse.availableDataSources[tokenResponse.availableDataSources.length - 1];
}
if (!dataSource) {
throw new Error('Guacamole did not return a data source');
}
return new GuacSession(client, base, dataSource, tokenResponse.authToken);
}
catch (error) {
console.error('Guacamole login failed:', error);
throw new Error('Guacamole login failed');
}
}
get authParams() {
return {
token: this.token,
dataSource: this.dataSource
};
}
async checkExistence(group, users, vms) {
// Check Group
try {
await this.client.get(`${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}`, { params: this.authParams });
// Group exists, which is fine, we'll update/use it
}
catch (error) {
// If 404, it's good, we'll create it
}
// Check Users
for (const user of users) {
try {
await this.client.get(`${this.base}/session/data/${this.dataSource}/users/${encodeURIComponent(user.username)}`, { params: this.authParams });
// User exists, fine
}
catch (error) {
// If 404, fine
}
}
// Check Connections
for (const vm of vms) {
const existing = await this.findConnectionIdentifier(vm.name);
if (existing) {
// Connection exists, fine
}
}
}
async ensureUserGroup(identifier, displayName) {
const url = `${this.base}/session/data/${this.dataSource}/userGroups`;
const body = {
identifier,
name: displayName,
attributes: {
"disabled": "",
"expired": "",
"valid-from": "",
"valid-until": "",
}
};
try {
await this.client.post(url, body, { params: this.authParams });
}
catch (error) {
if (error.response && error.response.status === 409)
return; // Conflict is OK
throw error;
}
}
async createOrUpdateUser(user) {
const url = `${this.base}/session/data/${this.dataSource}/users`;
const body = {
username: user.username,
password: user.password,
attributes: {
"disabled": "",
"expired": "",
"valid-from": "",
"valid-until": "",
"guac-full-name": user.displayName || "",
"guac-organization": "",
"guac-organizational-role": ""
}
};
try {
await this.client.post(url, body, { params: this.authParams });
}
catch (error) {
if (error.response && error.response.status === 409) {
// Update
const updateUrl = `${url}/${encodeURIComponent(user.username)}`;
await this.client.put(updateUrl, body, { params: this.authParams });
}
else {
throw error;
}
}
}
async createOrUpdateConnection(groupIdentifier, vm) {
const url = `${this.base}/session/data/${this.dataSource}/connections`;
const hostname = vm.ip.split('/')[0] || vm.ip;
const port = (vm.rdpPort || DEFAULT_RDP_PORT).toString();
const parameters = {
hostname,
port,
security: "any",
"ignore-cert": "true",
username: "dev",
password: "Formation123!"
};
if (vm.rdpDomain) {
parameters.domain = vm.rdpDomain;
}
const body = {
name: vm.name,
parentIdentifier: "ROOT",
protocol: "rdp",
parameters,
attributes: {
"max-connections": "",
"max-connections-per-user": "",
"weight": "",
"failover-only": ""
}
};
try {
const response = await this.client.post(url, body, { params: this.authParams });
return response.data.identifier; // Assuming response contains identifier in body or we extract it
}
catch (error) {
if (error.response && error.response.status === 409) {
const existing = await this.findConnectionIdentifier(vm.name);
if (existing)
return existing;
throw new Error(`Connection '${vm.name}' already exists but identifier could not be resolved`);
}
throw error;
}
}
async findConnectionIdentifier(name) {
const url = `${this.base}/session/data/${this.dataSource}/connections`;
const response = await this.client.get(url, { params: this.authParams });
const connections = response.data;
if (connections && typeof connections === 'object') {
for (const key in connections) {
if (connections[key].name === name) {
return connections[key].identifier;
}
}
}
return null;
}
async setGroupMembers(group, users) {
const url = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}/memberUsers`;
const ops = users.map(user => ({
op: "add",
path: "/",
value: user.username
}));
await this.client.patch(url, ops, {
params: this.authParams,
headers: { 'Content-Type': 'application/json' }
});
}
async grantGroupConnection(group, connectionId) {
const url = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}/permissions`;
const ops = [{
op: "add",
path: `/connectionPermissions/${connectionId}`,
value: "READ"
}];
await this.client.patch(url, ops, {
params: this.authParams,
headers: { 'Content-Type': 'application/json' }
});
}
async grantUserConnection(username, connectionId) {
const url = `${this.base}/session/data/${this.dataSource}/users/${encodeURIComponent(username)}/permissions`;
const ops = [{
op: "add",
path: `/connectionPermissions/${connectionId}`,
value: "READ"
}];
await this.client.patch(url, ops, {
params: this.authParams,
headers: { 'Content-Type': 'application/json' }
});
}
async getGroupMembers(group) {
const usersUrl = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}/memberUsers`;
const usersRes = await this.client.get(usersUrl, { params: this.authParams });
const users = usersRes.data || [];
const permsUrl = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}/permissions`;
const permsRes = await this.client.get(permsUrl, { params: this.authParams });
const connections = [];
if (permsRes.data && permsRes.data.connectionPermissions) {
connections.push(...Object.keys(permsRes.data.connectionPermissions));
}
// Also fetch user permissions
for (const user of users) {
const userPermsUrl = `${this.base}/session/data/${this.dataSource}/users/${encodeURIComponent(user)}/permissions`;
try {
const userPermsRes = await this.client.get(userPermsUrl, { params: this.authParams });
if (userPermsRes.data && userPermsRes.data.connectionPermissions) {
connections.push(...Object.keys(userPermsRes.data.connectionPermissions));
}
}
catch (e) {
// ignore
}
}
return { users, connections: [...new Set(connections)] };
}
async deleteUser(username) {
const url = `${this.base}/session/data/${this.dataSource}/users/${encodeURIComponent(username)}`;
await this.client.delete(url, { params: this.authParams });
}
async deleteGroup(group) {
const url = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}`;
await this.client.delete(url, { params: this.authParams });
}
async deleteConnection(identifier) {
const url = `${this.base}/session/data/${this.dataSource}/connections/${encodeURIComponent(identifier)}`;
await this.client.delete(url, { params: this.authParams });
}
}
exports.GuacSession = GuacSession;

22
dist/utils/helpers.js vendored Normal file
View File

@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.clearSessionCookie = exports.setSessionCookie = void 0;
const setSessionCookie = (res, token) => {
res.cookie('token', token, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true', // Default to false if not set
sameSite: 'lax',
maxAge: 3600 * 1000, // 1 hour
path: '/'
});
};
exports.setSessionCookie = setSessionCookie;
const clearSessionCookie = (res) => {
res.clearCookie('token', {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
path: '/'
});
};
exports.clearSessionCookie = clearSessionCookie;

43
dist/utils/logger.js vendored Normal file
View File

@ -0,0 +1,43 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const winston_1 = __importDefault(require("winston"));
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
const level = () => {
const env = process.env.NODE_ENV || 'development';
const isDevelopment = env === 'development';
return isDevelopment ? 'debug' : 'warn';
};
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white',
};
winston_1.default.addColors(colors);
const format = winston_1.default.format.combine(winston_1.default.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), winston_1.default.format.colorize({ all: true }), winston_1.default.format.printf((info) => `${info.timestamp} ${info.level}: ${info.message}`));
const transports = [
new winston_1.default.transports.Console(),
new winston_1.default.transports.File({
filename: 'logs/error.log',
level: 'error',
format: winston_1.default.format.json(),
}),
new winston_1.default.transports.File({ filename: 'logs/all.log', format: winston_1.default.format.json() }),
];
const logger = winston_1.default.createLogger({
level: level(),
levels,
format,
transports,
});
exports.default = logger;

5
dist/utils/prisma.js vendored Normal file
View File

@ -0,0 +1,5 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const prisma_1 = require("../generated/prisma");
const prisma = new prisma_1.PrismaClient();
exports.default = prisma;

111
dist/utils/shell.js vendored Normal file
View File

@ -0,0 +1,111 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.copyDirectorySsh = exports.rsync = exports.uploadFileSsh = exports.executeSsh = exports.escapeShell = exports.executeCommand = void 0;
const child_process_1 = require("child_process");
const config_1 = __importDefault(require("../config"));
const executeCommand = (command, args, input) => {
return new Promise((resolve, reject) => {
const child = (0, child_process_1.spawn)(command, args);
let stdout = '';
let stderr = '';
if (input && child.stdin) {
child.stdin.write(input);
child.stdin.end();
}
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({ code, stdout, stderr });
});
child.on('error', (err) => {
reject(err);
});
});
};
exports.executeCommand = executeCommand;
const escapeShell = (arg) => {
return `'${arg.replace(/'/g, "'\\''")}'`;
};
exports.escapeShell = escapeShell;
const ALLOWED_SSH_COMMANDS = ['terraform', 'mkdir', 'cp', 'cd', 'rm', 'test', 'cat'];
const validateSshCommand = (cmd) => {
// Split by common shell operators: &&, ||, ;, |
// This is a basic parser to catch obvious unauthorized commands.
const parts = cmd.split(/&&|\|\||;|\|/);
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed)
continue;
// Get the first word (command executable)
// We handle cases like "VAR=val cmd" by ignoring leading vars?
// For now, we assume standard "cmd args" format as used in the app.
const command = trimmed.split(/\s+/)[0];
if (!ALLOWED_SSH_COMMANDS.includes(command)) {
throw new Error(`Security Error: Command '${command}' is not allowed via SSH. Allowed: ${ALLOWED_SSH_COMMANDS.join(', ')}`);
}
}
};
const executeSsh = async (remoteCmd) => {
validateSshCommand(remoteCmd);
const args = [
'-i', config_1.default.terraform.sshKeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
`${config_1.default.terraform.sshUser}@${config_1.default.terraform.sshHost}`,
remoteCmd
];
return (0, exports.executeCommand)('ssh', args);
};
exports.executeSsh = executeSsh;
const uploadFileSsh = async (remotePath, content) => {
const escapedPath = (0, exports.escapeShell)(remotePath);
const args = [
'-i', config_1.default.terraform.sshKeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
`${config_1.default.terraform.sshUser}@${config_1.default.terraform.sshHost}`,
`cat > ${escapedPath}`
];
return (0, exports.executeCommand)('ssh', args, content);
};
exports.uploadFileSsh = uploadFileSsh;
const rsync = async (src, dest) => {
const sshCmd = `ssh -i ${config_1.default.terraform.sshKeyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
const remoteDest = `${config_1.default.terraform.sshUser}@${config_1.default.terraform.sshHost}:${dest}`;
const args = [
'-az',
'--delete',
'-e', sshCmd,
src,
remoteDest
];
return (0, exports.executeCommand)('rsync', args);
};
exports.rsync = rsync;
const copyDirectorySsh = async (src, dest) => {
// We use scp -r to copy the directory.
// Note: If dest does not exist, scp copies src as dest.
// If dest exists, scp copies src INTO dest (creating dest/src).
// The caller should ensure dest does not exist if they want 'src' content to be 'dest'.
const remoteDest = `${config_1.default.terraform.sshUser}@${config_1.default.terraform.sshHost}:${dest}`;
const args = [
'-i', config_1.default.terraform.sshKeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-r',
src,
remoteDest
];
return (0, exports.executeCommand)('scp', args);
};
exports.copyDirectorySsh = copyDirectorySsh;

View File

@ -17,6 +17,7 @@ const vmSchema = z.object({
disk_size: z.number(),
template: z.string(),
ip: z.string(),
guacamoleIp: z.string().optional(),
bridge: z.string().optional(),
model: z.enum(['windows', 'linux']).default('linux'),
rdpDomain: z.string().optional(),
@ -50,6 +51,7 @@ const isValidFormationId = (id: string): boolean => {
export const apply = async (req: Request, res: Response): Promise<void> => {
try {
logger.info(`Apply request body: ${JSON.stringify(req.body)}`);
const body = applySchema.parse(req.body);
let formationId = cleanFormationId(body.formationId);

View File

@ -75,16 +75,19 @@ export const handleApplyJob = async (formationId: string, req: ApplyRequest) =>
let guacamolePromise: Promise<void> = Promise.resolve();
if (req.guacamole) {
logger.info(`Starting Guacamole provisioning for formation ${formationId}`);
const guacamoleVms = req.vms.map(vm => ({
name: vm.name,
ip: vm.guacamoleIp || vm.ip,
rdpDomain: vm.rdpDomain,
rdpPort: vm.rdpPort,
}));
logger.info(`Provisioning Guacamole with VMs: ${JSON.stringify(guacamoleVms)}`);
guacamolePromise = guacamoleService.provision(
formationId,
req.guacamole.groupName,
req.guacamole.users,
req.vms.map(vm => ({
name: vm.name,
ip: vm.ip,
rdpDomain: vm.rdpDomain,
rdpPort: vm.rdpPort,
}))
guacamoleVms
).then(() => {
logger.info(`Guacamole provisioning succeeded for formation ${formationId}`);
}).catch((error: any) => {

View File

@ -6,6 +6,7 @@ export interface VmDefinition {
disk_size: number;
template: string;
ip: string;
guacamoleIp?: string;
bridge?: string;
model: 'windows' | 'linux';
rdpDomain?: string;