Refacto de l'API
This commit is contained in:
parent
1202357a32
commit
741da58194
@ -11,23 +11,23 @@ const envSchema = z.object({
|
||||
APP_ADMIN_EMAIL: z.string().optional(),
|
||||
APP_ADMIN_PASSWORD: z.string().optional(),
|
||||
// Guacamole
|
||||
GUACAMOLE_API_ENDPOINT: z.string().default('https://guacamole.firewax.fr/guacamole/api'),
|
||||
GUACAMOLE_USERNAME: z.string().default('guacadmin'),
|
||||
GUACAMOLE_PASSWORD: z.string().default('guacadmin'),
|
||||
GUACAMOLE_API_ENDPOINT: z.string().default('https://guacamole.local/guacamole/api'),
|
||||
GUACAMOLE_USERNAME: z.string().default('user'),
|
||||
GUACAMOLE_PASSWORD: z.string().default('passsword'),
|
||||
// Terraform
|
||||
TERRAFORM_DROP_DIR: z.string().default('terraform_drop'),
|
||||
TERRAFORM_TEMPLATE_PATH: z.string().default('/home/corenthin/terraform'),
|
||||
TERRAFORM_REMOTE_TEMPLATE_PATH: z.string().default('/home/corenthin/terraform'),
|
||||
TERRAFORM_DEPLOY_BASE_PATH: z.string().default('/home/corenthin/'),
|
||||
TERRAFORM_TEMPLATE_PATH: z.string().default('/home/user/terraform'),
|
||||
TERRAFORM_REMOTE_TEMPLATE_PATH: z.string().default('/home/user/terraform'),
|
||||
TERRAFORM_DEPLOY_BASE_PATH: z.string().default('/home/user/'),
|
||||
TERRAFORM_SSH_HOST: z.string().default('127.0.0.1'),
|
||||
TERRAFORM_SSH_USER: z.string().default('corenthin'),
|
||||
TERRAFORM_SSH_KEY_PATH: z.string().default('/home/corenthin/.ssh/testapi'),
|
||||
TERRAFORM_SSH_USER: z.string().default('user'),
|
||||
TERRAFORM_SSH_KEY_PATH: z.string().default('/home/user/.ssh/testapi'),
|
||||
// Proxmox
|
||||
PROXMOX_URL: z.string().default('https://node1.solyone.fr:8006/api2/json'),
|
||||
PROXMOX_TOKEN_ID: z.string().default('terraform-prov@pve!mytoken'),
|
||||
PROXMOX_TOKEN_SECRET: z.string().default('a4b8720c-6e69-4309-ab56-54b62126b6e6'),
|
||||
PROXMOX_URL: z.string().default('https://localhost:8006/api2/json'),
|
||||
PROXMOX_TOKEN_ID: z.string().default('tokenid'),
|
||||
PROXMOX_TOKEN_SECRET: z.string().default('tokensecret'),
|
||||
PROXMOX_INSECURE_TLS: z.string().default('true'),
|
||||
PROXMOX_TARGET_NODE: z.string().default('node1'),
|
||||
PROXMOX_TARGET_NODE: z.string().default('pve'),
|
||||
PROXMOX_MODEL_WINDOWS: z.string().default('e1000'),
|
||||
PROXMOX_MODEL_LINUX: z.string().default('virtio'),
|
||||
// Cors
|
||||
|
||||
@ -6,6 +6,7 @@ import * as terraformService from '../services/terraformService';
|
||||
import * as proxmoxService from '../services/proxmoxService';
|
||||
import path from 'path';
|
||||
import logger from '../utils/logger';
|
||||
import { ApplyRequest } from '../types/terraform';
|
||||
|
||||
// Zod schemas
|
||||
const vmSchema = z.object({
|
||||
@ -67,7 +68,7 @@ export const apply = async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
|
||||
// Spawn background job
|
||||
terraformService.handleApplyJob(formationId, body as any).catch(err => {
|
||||
terraformService.handleApplyJob(formationId, body as unknown as ApplyRequest).catch(err => {
|
||||
logger.error(`Apply job failed for ${formationId}: ${err}`);
|
||||
});
|
||||
|
||||
|
||||
@ -1,313 +1,6 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import config from '../config';
|
||||
import { GuacSession, GuacamoleUser, GuacamoleVm } from '../utils/guacamoleClient';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
interface GuacamoleUser {
|
||||
username: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface GuacamoleVm {
|
||||
name: string;
|
||||
ip: string;
|
||||
rdpPort?: number;
|
||||
rdpDomain?: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
authToken: string;
|
||||
dataSource?: string;
|
||||
availableDataSources?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_RDP_PORT = 3389;
|
||||
|
||||
class GuacSession {
|
||||
private client: AxiosInstance;
|
||||
private base: string;
|
||||
private dataSource: string;
|
||||
private token: string;
|
||||
|
||||
private constructor(client: AxiosInstance, base: string, dataSource: string, token: string) {
|
||||
this.client = client;
|
||||
this.base = base;
|
||||
this.dataSource = dataSource;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static async login(): Promise<GuacSession> {
|
||||
const { apiEndpoint, username, password } = config.guacamole;
|
||||
const base = apiEndpoint.replace(/\/$/, '');
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
const client = axios.create({
|
||||
httpsAgent: agent,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('username', username);
|
||||
params.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await client.post<TokenResponse>(`${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');
|
||||
}
|
||||
}
|
||||
|
||||
private get authParams() {
|
||||
return {
|
||||
token: this.token,
|
||||
dataSource: this.dataSource
|
||||
};
|
||||
}
|
||||
|
||||
async checkExistence(group: string, users: GuacamoleUser[], vms: GuacamoleVm[]): Promise<void> {
|
||||
// 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: any) {
|
||||
// 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: any) {
|
||||
// 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: string, displayName: string): Promise<void> {
|
||||
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: any) {
|
||||
if (error.response && error.response.status === 409) return; // Conflict is OK
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createOrUpdateUser(user: GuacamoleUser): Promise<void> {
|
||||
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: any) {
|
||||
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: string, vm: GuacamoleVm): Promise<string> {
|
||||
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: any = {
|
||||
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: any) {
|
||||
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: string): Promise<string | null> {
|
||||
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: string, users: GuacamoleUser[]): Promise<void> {
|
||||
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: string, connectionId: string): Promise<void> {
|
||||
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: string, connectionId: string): Promise<void> {
|
||||
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: string): Promise<{ users: string[], connections: string[] }> {
|
||||
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: string[] = [];
|
||||
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: string): Promise<void> {
|
||||
const url = `${this.base}/session/data/${this.dataSource}/users/${encodeURIComponent(username)}`;
|
||||
await this.client.delete(url, { params: this.authParams });
|
||||
}
|
||||
|
||||
async deleteGroup(group: string): Promise<void> {
|
||||
const url = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}`;
|
||||
await this.client.delete(url, { params: this.authParams });
|
||||
}
|
||||
|
||||
async deleteConnection(identifier: string): Promise<void> {
|
||||
const url = `${this.base}/session/data/${this.dataSource}/connections/${encodeURIComponent(identifier)}`;
|
||||
await this.client.delete(url, { params: this.authParams });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to extract numeric suffix for matching
|
||||
const extractNumericSuffix = (s: string): number | null => {
|
||||
|
||||
@ -6,36 +6,17 @@ import * as tfvarsService from './tfvarsService';
|
||||
import * as shell from '../utils/shell';
|
||||
import * as guacamoleService from './guacamoleService';
|
||||
import logger from '../utils/logger';
|
||||
import { ApplyRequest } from '../types/terraform';
|
||||
|
||||
interface VmDefinition {
|
||||
name: string;
|
||||
vmid: number;
|
||||
cores: number;
|
||||
memory: number;
|
||||
disk_size: number;
|
||||
template: string;
|
||||
ip: string;
|
||||
bridge?: string;
|
||||
model: 'windows' | 'linux';
|
||||
rdpDomain?: string;
|
||||
rdpPort?: number;
|
||||
}
|
||||
|
||||
interface GuacamoleConfig {
|
||||
groupName?: string;
|
||||
users: { username: string; password: string }[];
|
||||
}
|
||||
|
||||
export interface ApplyRequest {
|
||||
formationId: string;
|
||||
vms: VmDefinition[];
|
||||
guacamole?: GuacamoleConfig;
|
||||
}
|
||||
|
||||
const prepareRemoteWorkspace = async (formationId: string): Promise<string> => {
|
||||
const remoteDir = path.posix.join(config.terraform.deployBasePath, formationId);
|
||||
const templateSrc = config.terraform.remoteTemplatePath;
|
||||
await ensureRemoteDirectory(remoteDir);
|
||||
await copyTemplateToRemote(remoteDir);
|
||||
return remoteDir;
|
||||
};
|
||||
|
||||
const ensureRemoteDirectory = async (remoteDir: string) => {
|
||||
// Check if exists and cleanup
|
||||
const checkCmd = `test -d ${shell.escapeShell(remoteDir)}`;
|
||||
const checkRes = await shell.executeSsh(checkCmd);
|
||||
@ -43,7 +24,10 @@ const prepareRemoteWorkspace = async (formationId: string): Promise<string> => {
|
||||
logger.warn(`Remote directory ${remoteDir} exists. Cleaning up before copy.`);
|
||||
await cleanupRemote(remoteDir);
|
||||
}
|
||||
};
|
||||
|
||||
const copyTemplateToRemote = async (remoteDir: string) => {
|
||||
const templateSrc = config.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)}`;
|
||||
@ -52,8 +36,6 @@ const prepareRemoteWorkspace = async (formationId: string): Promise<string> => {
|
||||
if (copyRes.code !== 0) {
|
||||
throw new Error(`Remote copy failed: ${copyRes.stderr}`);
|
||||
}
|
||||
|
||||
return remoteDir;
|
||||
};
|
||||
|
||||
const cleanupRemote = async (remoteDir: string) => {
|
||||
@ -61,6 +43,15 @@ const cleanupRemote = async (remoteDir: string) => {
|
||||
await shell.executeSsh(cmd);
|
||||
};
|
||||
|
||||
const runTerraformCommand = async (remoteDir: string, command: string) => {
|
||||
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;
|
||||
};
|
||||
|
||||
export const handleApplyJob = async (formationId: string, req: ApplyRequest) => {
|
||||
const remoteDir = await prepareRemoteWorkspace(formationId);
|
||||
|
||||
@ -75,18 +66,10 @@ export const handleApplyJob = async (formationId: string, req: ApplyRequest) =>
|
||||
}
|
||||
|
||||
// Terraform init
|
||||
const initCmd = `cd ${shell.escapeShell(remoteDir)} && terraform init -input=false -upgrade`;
|
||||
const initRes = await shell.executeSsh(initCmd);
|
||||
if (initRes.code !== 0) {
|
||||
throw new Error(`Terraform init failed: ${initRes.stderr}`);
|
||||
}
|
||||
await runTerraformCommand(remoteDir, 'init -input=false -upgrade');
|
||||
|
||||
// Terraform apply
|
||||
const applyCmd = `cd ${shell.escapeShell(remoteDir)} && terraform apply -auto-approve -input=false`;
|
||||
const applyRes = await shell.executeSsh(applyCmd);
|
||||
if (applyRes.code !== 0) {
|
||||
throw new Error(`Terraform apply failed: ${applyRes.stderr}`);
|
||||
}
|
||||
await runTerraformCommand(remoteDir, 'apply -auto-approve -input=false');
|
||||
|
||||
// Save to DB
|
||||
await prisma.terraform_formations.create({
|
||||
@ -141,15 +124,9 @@ export const destroyFormation = async (formationId: string) => {
|
||||
|
||||
// Terraform destroy
|
||||
// We run init first to be safe, though state should be there
|
||||
const initCmd = `cd ${shell.escapeShell(remoteDir)} && terraform init -input=false`;
|
||||
await shell.executeSsh(initCmd);
|
||||
await runTerraformCommand(remoteDir, 'init -input=false');
|
||||
|
||||
const destroyCmd = `cd ${shell.escapeShell(remoteDir)} && terraform destroy -auto-approve -input=false -lock=false`;
|
||||
const destroyRes = await shell.executeSsh(destroyCmd);
|
||||
|
||||
if (destroyRes.code !== 0) {
|
||||
throw new Error(`Terraform destroy failed: ${destroyRes.stderr}`);
|
||||
}
|
||||
await runTerraformCommand(remoteDir, 'destroy -auto-approve -input=false -lock=false');
|
||||
|
||||
// Remove remote dir
|
||||
await cleanupRemote(remoteDir);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import config from '../config';
|
||||
import { VmDefinition, ApplyRequest } from '../types/terraform';
|
||||
|
||||
interface DeploymentRequest {
|
||||
name: string;
|
||||
@ -10,21 +11,6 @@ interface DeploymentRequest {
|
||||
ipStartHost?: number;
|
||||
}
|
||||
|
||||
interface TerraformVm {
|
||||
name: string;
|
||||
vmid: number;
|
||||
cores: number;
|
||||
memory: number;
|
||||
disk_size: number;
|
||||
template: string;
|
||||
ip: string;
|
||||
bridge?: string;
|
||||
model: 'windows' | 'linux';
|
||||
}
|
||||
|
||||
interface TerraformApplyRequest {
|
||||
vms: TerraformVm[];
|
||||
}
|
||||
|
||||
const escape = (s: string) => s.replace(/"/g, '\\"');
|
||||
|
||||
@ -49,7 +35,7 @@ export const generate = (req: DeploymentRequest): string => {
|
||||
return buf;
|
||||
};
|
||||
|
||||
export const generateVmDefinitions = (req: TerraformApplyRequest): string => {
|
||||
export const generateVmDefinitions = (req: ApplyRequest): string => {
|
||||
const cfg = config.proxmox;
|
||||
let buf = '';
|
||||
|
||||
@ -66,36 +52,47 @@ export const generateVmDefinitions = (req: TerraformApplyRequest): string => {
|
||||
buf += `vms = {\n`;
|
||||
|
||||
for (const vm of req.vms) {
|
||||
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`;
|
||||
|
||||
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;
|
||||
|
||||
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`;
|
||||
buf += ` }\n`;
|
||||
buf += generateVmBlock(vm, cfg);
|
||||
}
|
||||
|
||||
buf += `}\n`;
|
||||
return buf;
|
||||
};
|
||||
|
||||
const generateVmBlock = (vm: VmDefinition, cfg: any): string => {
|
||||
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: VmDefinition, cfg: any): string => {
|
||||
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;
|
||||
};
|
||||
|
||||
24
src/types/terraform.ts
Normal file
24
src/types/terraform.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export interface VmDefinition {
|
||||
name: string;
|
||||
vmid: number;
|
||||
cores: number;
|
||||
memory: number;
|
||||
disk_size: number;
|
||||
template: string;
|
||||
ip: string;
|
||||
bridge?: string;
|
||||
model: 'windows' | 'linux';
|
||||
rdpDomain?: string;
|
||||
rdpPort?: number;
|
||||
}
|
||||
|
||||
export interface GuacamoleConfig {
|
||||
groupName?: string;
|
||||
users: { username: string; password: string }[];
|
||||
}
|
||||
|
||||
export interface ApplyRequest {
|
||||
formationId: string;
|
||||
vms: VmDefinition[];
|
||||
guacamole?: GuacamoleConfig;
|
||||
}
|
||||
309
src/utils/guacamoleClient.ts
Normal file
309
src/utils/guacamoleClient.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import config from '../config';
|
||||
|
||||
export interface GuacamoleUser {
|
||||
username: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface GuacamoleVm {
|
||||
name: string;
|
||||
ip: string;
|
||||
rdpPort?: number;
|
||||
rdpDomain?: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
authToken: string;
|
||||
dataSource?: string;
|
||||
availableDataSources?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_RDP_PORT = 3389;
|
||||
|
||||
export class GuacSession {
|
||||
private client: AxiosInstance;
|
||||
private base: string;
|
||||
private dataSource: string;
|
||||
private token: string;
|
||||
|
||||
private constructor(client: AxiosInstance, base: string, dataSource: string, token: string) {
|
||||
this.client = client;
|
||||
this.base = base;
|
||||
this.dataSource = dataSource;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static async login(): Promise<GuacSession> {
|
||||
const { apiEndpoint, username, password } = config.guacamole;
|
||||
const base = apiEndpoint.replace(/\/$/, '');
|
||||
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
const client = axios.create({
|
||||
httpsAgent: agent,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('username', username);
|
||||
params.append('password', password);
|
||||
|
||||
try {
|
||||
const response = await client.post<TokenResponse>(`${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');
|
||||
}
|
||||
}
|
||||
|
||||
private get authParams() {
|
||||
return {
|
||||
token: this.token,
|
||||
dataSource: this.dataSource
|
||||
};
|
||||
}
|
||||
|
||||
async checkExistence(group: string, users: GuacamoleUser[], vms: GuacamoleVm[]): Promise<void> {
|
||||
// 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: any) {
|
||||
// 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: any) {
|
||||
// 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: string, displayName: string): Promise<void> {
|
||||
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: any) {
|
||||
if (error.response && error.response.status === 409) return; // Conflict is OK
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createOrUpdateUser(user: GuacamoleUser): Promise<void> {
|
||||
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: any) {
|
||||
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: string, vm: GuacamoleVm): Promise<string> {
|
||||
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: any = {
|
||||
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: any) {
|
||||
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: string): Promise<string | null> {
|
||||
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: string, users: GuacamoleUser[]): Promise<void> {
|
||||
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: string, connectionId: string): Promise<void> {
|
||||
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: string, connectionId: string): Promise<void> {
|
||||
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: string): Promise<{ users: string[], connections: string[] }> {
|
||||
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: string[] = [];
|
||||
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: string): Promise<void> {
|
||||
const url = `${this.base}/session/data/${this.dataSource}/users/${encodeURIComponent(username)}`;
|
||||
await this.client.delete(url, { params: this.authParams });
|
||||
}
|
||||
|
||||
async deleteGroup(group: string): Promise<void> {
|
||||
const url = `${this.base}/session/data/${this.dataSource}/userGroups/${encodeURIComponent(group)}`;
|
||||
await this.client.delete(url, { params: this.authParams });
|
||||
}
|
||||
|
||||
async deleteConnection(identifier: string): Promise<void> {
|
||||
const url = `${this.base}/session/data/${this.dataSource}/connections/${encodeURIComponent(identifier)}`;
|
||||
await this.client.delete(url, { params: this.authParams });
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user