Compare commits

...

68 Commits
master ... dev

Author SHA1 Message Date
f08de80144 Test2 2025-12-12 16:31:50 +01:00
2838b70a27 Test 2025-12-12 14:49:32 +01:00
dc38b80db1 Actualiser Jenkinsfile 2025-12-12 10:59:33 +01:00
5ce6abe268 Actualiser Jenkinsfile 2025-12-12 00:04:26 +01:00
b33d2a51d2 Actualiser Jenkinsfile 2025-12-11 23:31:34 +01:00
cacba65fa2 Actualiser Jenkinsfile 2025-12-11 23:30:08 +01:00
0a65d1b5ed Actualiser Jenkinsfile 2025-12-11 23:25:52 +01:00
f071e9e609 Actualiser Jenkinsfile 2025-12-11 23:23:55 +01:00
80a965228b Modification URL Gitea 2025-12-11 23:04:12 +01:00
corenthin-lebreton
c99c1809c4 modif test erreur suppression VMs 2025-12-02 21:30:39 +01:00
corenthin-lebreton
be23cf5891 mise en place des tâches terra et guac en parallèle 2025-12-02 21:00:54 +01:00
corenthin-lebreton
74b0639ac6 correction récupération templates 2025-12-02 20:47:25 +01:00
corenthin-lebreton
741da58194 Refacto de l'API 2025-12-02 20:40:46 +01:00
corenthin-lebreton
1202357a32 modif terraform 2025-12-02 20:33:16 +01:00
corenthin-lebreton
e5268b1c43 modif 2025-12-02 20:25:40 +01:00
corenthin-lebreton
89c2029cbc detection type vm 2025-12-02 20:12:52 +01:00
corenthin-lebreton
a647109cc2 modification détection type de VM 2025-12-02 19:49:25 +01:00
corenthin-lebreton
e400dd8431 Test 2025-12-02 19:13:01 +01:00
corenthin-lebreton
4f7cb4fb2c test terraform 2025-12-02 19:04:42 +01:00
corenthin-lebreton
ce8d9ec9c4 modification du répertoire terraform 2025-12-02 18:52:26 +01:00
corenthin-lebreton
7cf90e4633 passage d'un docker run à terraform natif 2025-12-02 18:45:50 +01:00
corenthin-lebreton
d07963fc5d test terraform 2025-12-02 18:30:21 +01:00
corenthin-lebreton
7bc47bb156 changement gateway en fonction du mode réseau 2025-12-02 18:09:23 +01:00
corenthin-lebreton
ca556278db modification RSYNC par SCP 2025-12-02 17:57:11 +01:00
8d501a22d7 empty commit to push 2025-12-02 17:37:46 +01:00
62930463b0 modification Jenkinsfile + implémentation conteneur terraform 2025-12-02 15:52:44 +01:00
0c6d6d8c35 test 2025-12-01 16:26:21 +01:00
753d5b54fe modif 2025-12-01 16:17:05 +01:00
1d0646472c Modif Dockerfile 2025-12-01 16:11:44 +01:00
fab2c15059 modif 2025-12-01 16:07:58 +01:00
0cc99324e9 modif 2025-12-01 15:50:39 +01:00
d74fa6ddf7 ajout URL HTTPS dans le CORS 2025-12-01 15:38:47 +01:00
9612ba3f3a Use shell base64 instead of groovy method 2025-12-01 15:10:01 +01:00
3478db2062 empty commit to push 2025-12-01 14:47:31 +01:00
a2b23e8fa1 Jenkins modif 2025-12-01 14:43:56 +01:00
89d3d876ed Fix SSH key mount strategy 2025-11-28 23:12:46 +01:00
41fd95aaf5 Fix SSH key permissions in container 2025-11-28 23:09:51 +01:00
9880f89dff Relax cookie security for HTTP deployment 2025-11-28 22:50:18 +01:00
bdaf95090c Fix Prisma binary targets for Alpine/OpenSSL 3 2025-11-28 22:33:25 +01:00
6b43e097c4 modif jenkinsfile2 2025-11-28 22:31:04 +01:00
f5e1c2ccb9 modif jenkinsfile 2025-11-28 22:29:05 +01:00
23ed25a5d1 Use gcompat for Prisma on Alpine 2025-11-28 22:24:29 +01:00
3e539d8f96 Fix openssl compat package name 2025-11-28 22:22:49 +01:00
e7a8ca5eaf Add compat-openssl11 for Prisma 2025-11-28 22:20:44 +01:00
d5d9b2ce92 test fix prisma client 2025-11-28 22:17:46 +01:00
f913a39713 modif dockerfile 2025-11-28 22:14:04 +01:00
53f5f9849d Refonte de Rust à Typescript 2025-11-28 22:06:14 +01:00
624a86a9c1 test 2025-11-28 12:42:16 +01:00
c7a5e76c4c test 2025-11-28 12:34:16 +01:00
a9620edaa1 test modif 2025-11-28 12:28:21 +01:00
7534e8c825 modif Dockerfile 2025-11-28 12:03:33 +01:00
97849f6079 premier test pipeline 2025-11-28 11:52:52 +01:00
08268e6665 oui 2025-11-27 16:12:07 +01:00
5e89b9a4d1 changement full clone -> linked clone sur proxmox 2025-11-27 00:39:21 +01:00
69648caa08 modif api 2025-11-26 21:47:01 +01:00
408093c7b6 nouvelles modifs 2025-11-26 20:07:02 +01:00
5e633a6e56 modif 2025-11-25 23:47:28 +01:00
1ddef6114c modif' 2025-10-10 16:43:18 +02:00
7c1d4b048c miduf 2025-10-10 01:08:59 +02:00
d8c4982d49 Ajout de la feature création de formation 2025-10-09 15:58:12 +02:00
0c0bb8deba modification du handler 2025-10-08 16:57:51 +02:00
c909f37fad (add): admin management features 2025-10-08 15:48:14 +02:00
8dd2ac0265 modification 2025-10-07 15:42:09 +02:00
bba5834204 Refacto 2025-10-07 12:24:25 +02:00
08612e7ce5 modification de la récupération d'infos pour l'API vers la BDD 2025-10-07 12:17:24 +02:00
2c660b2d89 Refacto auth.rs 2025-10-07 11:48:55 +02:00
8e5b78ce77 (update):register and login features = optimization 2025-10-07 00:38:33 +02:00
d08818a86c (add):register + login + logout features 2025-10-06 15:41:43 +02:00
88 changed files with 6998 additions and 3744 deletions

18
.gitignore vendored
View File

@ -1,17 +1,5 @@
# Rust target
target/
**/*.rs.bk
# Env files
node_modules
# Keep environment variables out of version control
.env
.env.*
!.env.example
# Terraform drop/output directories
terraform_drop/
# OS/editor junk
.DS_Store
*.swp
.idea/
.vscode/
/src/generated/prisma

3099
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
[workspace]
members = [
"crates/api",
]
resolver = "2"
[workspace.package]
edition = "2021"
authors = ["Corenthin"]
license = "MIT"
[workspace.dependencies]
anyhow = "1"
axum = "0.7"
config = "0.14"
dotenvy = "0.15"
rand = { version = "0.8", features = ["std"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
sqlx = { version = "0.7", features = ["runtime-tokio", "mysql", "chrono", "uuid"] }
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
uuid = { version = "1", features = ["serde", "v4"] }

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src/generated ./dist/generated
RUN npm ci --only=production
RUN apk add --no-cache openssh-client rsync gcompat openssl
EXPOSE 3000
CMD ["sh", "-c", "echo \"$SSH_PRIVATE_KEY_BASE64\" | base64 -d > /tmp/id_rsa && chmod 600 /tmp/id_rsa && npx prisma db push && npm start"]

115
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,115 @@
pipeline {
options {
disableConcurrentBuilds()
}
agent any
environment {
SERVICE_NAME = 'ApiAutoDeploy'
DOCKER_REGISTRY_URL = 'http://172.75.13.27:3000/corenthin/apiautodeploy:latest'
DOCKER_TAG = '172.75.13.27:3000/corenthin/apiautodeploy:latest'
DEPLOY_HOST = '172.75.13.5'
DEPLOY_CREDENTIALS_ID = '1000'
DATABASE_URL_SECRET = '1001'
TERRAFORM_SSH_KEY_CREDENTIAL_ID = 'terraform-ssh-key'
PROXMOX_CREDENTIALS_ID = 'proxmox-api-credentials'
GUACAMOLE_CREDENTIALS_ID = 'guacamole-api-credentials'
JWT_SECRET_ID = 'api-jwt-secret'
}
stages {
stage('Checkout Code') {
steps {
checkout scm
}
}
stage('Build Docker Image') {
steps {
script {
env.IMAGE_TAG = "${env.DOCKER_TAG}"
echo "Construction de l'image Docker pour l'API TS avec le tag : ${env.IMAGE_TAG}"
sh "docker build -t ${env.IMAGE_TAG} ."
}
}
}
stage('Push Docker Image') {
steps {
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials', passwordVariable: 'DOCKER_PASSWORD', usernameVariable: 'DOCKER_USERNAME')]) {
script {
def registryHostPort = env.DOCKER_REGISTRY_URL.split('//')[1].split('/')[0]
sh "echo \$DOCKER_PASSWORD | docker login -u \$DOCKER_USERNAME --password-stdin 172.75.13.27:3000"
}
sh "docker push ${env.IMAGE_TAG}"
sh "docker rmi ${env.IMAGE_TAG}"
}
}
}
stage('Deploy on VM') {
steps {
withCredentials([
string(credentialsId: env.DATABASE_URL_SECRET, variable: 'DATABASE_URL_SECRET'),
sshUserPrivateKey(credentialsId: env.TERRAFORM_SSH_KEY_CREDENTIAL_ID, keyFileVariable: 'SSH_KEY_FILE'),
usernamePassword(credentialsId: env.PROXMOX_CREDENTIALS_ID, usernameVariable: 'PROXMOX_TOKEN_ID', passwordVariable: 'PROXMOX_TOKEN_SECRET'),
usernamePassword(credentialsId: env.GUACAMOLE_CREDENTIALS_ID, usernameVariable: 'GUACAMOLE_USERNAME', passwordVariable: 'GUACAMOLE_PASSWORD'),
string(credentialsId: env.JWT_SECRET_ID, variable: 'JWT_SECRET'),
string(credentialsId: 'proxmox-url', variable: 'PROXMOX_URL'),
string(credentialsId: 'guacamole-url', variable: 'GUACAMOLE_API_ENDPOINT')
]) {
script {
def sshKeyBase64 = sh(script: "base64 -w 0 ${SSH_KEY_FILE}", returnStdout: true).trim()
def deployScript = """
docker pull ${env.IMAGE_TAG}
docker rm -f ${env.SERVICE_NAME} || true
echo "Démarrage du nouveau container..."
docker run -d --name ${env.SERVICE_NAME} \\
-p 4447:3000 \\
-v /home/corenthin/terraform:/app/templates:ro \\
-e DATABASE_URL="${DATABASE_URL_SECRET}" \\
-e JWT_SECRET="${JWT_SECRET}" \\
-e PROXMOX_URL="${PROXMOX_URL}" \\
-e PROXMOX_TOKEN_ID="${PROXMOX_TOKEN_ID}" \\
-e PROXMOX_TOKEN_SECRET="${PROXMOX_TOKEN_SECRET}" \\
-e PROXMOX_INSECURE_TLS="true" \\
-e GUACAMOLE_API_ENDPOINT="${GUACAMOLE_API_ENDPOINT}" \\
-e GUACAMOLE_USERNAME="${GUACAMOLE_USERNAME}" \\
-e GUACAMOLE_PASSWORD="${GUACAMOLE_PASSWORD}" \\
-e TERRAFORM_SSH_KEY_PATH="/tmp/id_rsa" \\
-e SSH_PRIVATE_KEY_BASE64="${sshKeyBase64}" \\
-e TERRAFORM_TEMPLATE_PATH="/app/templates" \\
-e TERRAFORM_SSH_HOST="${env.DEPLOY_HOST}" \\
${env.IMAGE_TAG}
docker image prune -f --filter "until=24h"
"""
sshagent(credentials: [env.DEPLOY_CREDENTIALS_ID]) {
sh "ssh -o StrictHostKeyChecking=no corenthin@${env.DEPLOY_HOST} \"${deployScript}\""
}
}
}
}
}
stage('Cleanup') {
steps {
cleanWs()
}
}
}
post {
always {
echo "Pipeline Backend TS terminée. Image déployée: ${env.IMAGE_TAG}"
}
failure {
echo '!!! ÉCHEC de la pipeline Backend TS !!!'
}
}
}

View File

@ -1,2 +0,0 @@
# apiprojetsolyti

View File

@ -1,22 +0,0 @@
[service]
host = "127.0.0.1"
port = 3000
[database]
url = "mysql://user:password@localhost:3306/apiprojetsolyti"
max_connections = 10
[guacamole]
api_endpoint = "https://guacamole.local/api"
username = ""
password = ""
[pfsense]
api_endpoint = "https://pfsense.local/api/v1"
api_key = ""
api_secret = ""
[terraform]
# Local drop directory where API writes generated tfvars files
drop_dir = "terraform_drop"

View File

@ -1 +0,0 @@
/target

View File

@ -1,22 +0,0 @@
[package]
name = "api"
version = "0.1.0"
edition = "2021"
description = "Automation API for Guacamole and pfSense orchestration with Terraform tfvars generation"
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
config = { workspace = true }
dotenvy = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
schemars = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }

View File

@ -1,30 +0,0 @@
use anyhow::Result;
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{config::AppConfig, routes};
pub async fn run() -> Result<()> {
init_tracing();
let cfg = AppConfig::load()?;
let router = routes::build_router(cfg.clone()).await?;
let addr: SocketAddr = format!("{}:{}", cfg.service.host, cfg.service.port).parse()?;
tracing::info!(%addr, "Starting HTTP server");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
}
fn init_tracing() {
let filter_layer = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let fmt_layer = tracing_subscriber::fmt::layer();
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.init();
}

View File

@ -1,16 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: "mysql://user:password@localhost:3306/apiprojetsolyti".to_string(),
max_connections: 10,
}
}
}

View File

@ -1,79 +0,0 @@
pub mod database;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub service: ServiceConfig,
pub database: database::DatabaseConfig,
pub guacamole: GuacamoleConfig,
pub pfsense: PfSenseConfig,
pub terraform: TerraformConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GuacamoleConfig {
pub api_endpoint: String,
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PfSenseConfig {
pub api_endpoint: String,
pub api_key: String,
pub api_secret: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct TerraformConfig {
pub drop_dir: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
service: ServiceConfig {
host: "127.0.0.1".to_string(),
port: 3000,
},
database: database::DatabaseConfig::default(),
guacamole: GuacamoleConfig {
api_endpoint: String::new(),
username: String::new(),
password: String::new(),
},
pfsense: PfSenseConfig {
api_endpoint: String::new(),
api_key: String::new(),
api_secret: String::new(),
},
terraform: TerraformConfig {
drop_dir: "terraform_drop".to_string(),
},
}
}
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
let _ = dotenvy::dotenv();
let builder = config::Config::builder()
.add_source(config::File::with_name("config/default.toml").required(false))
.add_source(
config::Environment::with_prefix("APP")
.try_parsing(true)
.separator("__"),
);
let cfg = builder.build()?;
cfg.try_deserialize().map_err(Into::into)
}
}

View File

@ -1,18 +0,0 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Deployment {
pub id: Uuid,
pub name: String,
pub user_count: u32,
}
impl Deployment {
pub fn new(name: impl Into<String>, user_count: u32) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
user_count,
}
}
}

View File

@ -1,5 +0,0 @@
pub mod deployments;
pub mod users;
pub use deployments::Deployment;
pub use users::{GuacamoleUser, VmAssignment};

View File

@ -1,26 +0,0 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct GuacamoleUser {
pub id: Uuid,
pub username: String,
pub password: String,
}
#[derive(Debug, Clone)]
pub struct VmAssignment {
pub user_id: Uuid,
pub vm_id: Uuid,
pub ip_address: String,
pub mac_address: String,
}
impl GuacamoleUser {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
username: username.into(),
password: password.into(),
}
}
}

View File

@ -1,16 +0,0 @@
use anyhow::Result;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
use crate::config::database::DatabaseConfig;
pub type DatabasePool = MySqlPool;
pub async fn create_pool(config: &DatabaseConfig) -> Result<DatabasePool> {
let pool = MySqlPoolOptions::new()
.max_connections(config.max_connections)
.connect(&config.url)
.await?;
Ok(pool)
}

View File

@ -1,19 +0,0 @@
use anyhow::Result;
use crate::config::GuacamoleConfig;
#[derive(Clone)]
pub struct GuacamoleClient {
config: GuacamoleConfig,
}
impl GuacamoleClient {
pub fn new(config: GuacamoleConfig) -> Self {
Self { config }
}
pub async fn health_check(&self) -> Result<()> {
tracing::debug!(endpoint = %self.config.api_endpoint, "Guacamole client health check placeholder");
Ok(())
}
}

View File

@ -1,6 +0,0 @@
pub mod database;
pub mod guacamole_client;
pub mod pf_sense_client;
pub mod terraform_executor;
pub use database::DatabasePool;

View File

@ -1,19 +0,0 @@
use anyhow::Result;
use crate::config::PfSenseConfig;
#[derive(Clone)]
pub struct PfSenseClient {
config: PfSenseConfig,
}
impl PfSenseClient {
pub fn new(config: PfSenseConfig) -> Self {
Self { config }
}
pub async fn push_mapping(&self) -> Result<()> {
tracing::debug!(endpoint = %self.config.api_endpoint, "pfSense client mapping placeholder");
Ok(())
}
}

View File

@ -1,32 +0,0 @@
use anyhow::Result;
use tokio::process::Command;
#[derive(Default, Clone)]
pub struct TerraformExecutor;
impl TerraformExecutor {
pub async fn apply(&self, working_dir: &str) -> Result<()> {
run_command("apply", working_dir).await
}
pub async fn destroy(&self, working_dir: &str) -> Result<()> {
run_command("destroy", working_dir).await
}
}
async fn run_command(subcommand: &str, working_dir: &str) -> Result<()> {
tracing::debug!(subcommand, working_dir, "Terraform command placeholder");
let status = Command::new("terraform")
.arg(subcommand)
.arg("-auto-approve")
.current_dir(working_dir)
.status()
.await?;
if !status.success() {
anyhow::bail!("Terraform command `{}` failed", subcommand);
}
Ok(())
}

View File

@ -1,11 +0,0 @@
mod bootstrap;
pub mod config;
pub mod domain;
pub mod infrastructure;
pub mod routes;
pub mod services;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
bootstrap::run().await
}

View File

@ -1,118 +0,0 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{config::AppConfig, services::tfvars::TfvarsGenerator};
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self { config }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeploymentRequest {
pub name: String,
pub user_count: u32,
// VM characteristics
pub vm_template: String,
pub cpu: u32,
pub ram_mb: u32,
pub disk_gb: u32,
// Optional starting host offset within 192.168.143.0/24
pub ip_start_host: Option<u8>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeploymentResponse {
pub id: Uuid,
pub tfvars_path: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorResponse {
pub error: String,
}
pub async fn create_deployment(
State(state): State<AppState>,
Json(payload): Json<DeploymentRequest>,
) -> impl IntoResponse {
if let Err(msg) = validate_request(&payload) {
return (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg })).into_response();
}
let id = Uuid::new_v4();
let tfvars = TfvarsGenerator::generate(&payload);
let drop_dir = &state.config.terraform.drop_dir;
let filename = format!("{}-{}.tfvars", payload.name.replace(' ', "_"), id);
let path = format!("{}/{}", drop_dir, filename);
if let Err(e) = write_tfvars(&path, &tfvars).await {
tracing::error!(error = %e, %path, "Failed to write tfvars file");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to write tfvars file".into(),
}),
)
.into_response();
}
Json(DeploymentResponse {
id,
tfvars_path: path,
})
.into_response()
}
async fn write_tfvars(path: &str, content: &str) -> anyhow::Result<()> {
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content).await?;
Ok(())
}
fn validate_request(req: &DeploymentRequest) -> Result<(), String> {
if req.name.trim().is_empty() {
return Err("name must not be empty".into());
}
if req.user_count == 0 {
return Err("user_count must be >= 1".into());
}
if req.cpu == 0 {
return Err("cpu must be >= 1".into());
}
if req.ram_mb < 256 {
return Err("ram_mb must be >= 256".into());
}
if req.disk_gb < 10 {
return Err("disk_gb must be >= 10".into());
}
let start = req.ip_start_host.unwrap_or(50) as u32;
if start < 2 || start > 254 {
return Err("ip_start_host must be in [2, 254]".into());
}
let last = start + req.user_count.saturating_sub(1) as u32;
if last > 254 {
return Err("ip range exceeds 192.168.143.254".into());
}
Ok(())
}
pub async fn schema() -> impl IntoResponse {
let schema = schemars::schema_for!(DeploymentRequest);
Json(schema)
}

View File

@ -1,6 +0,0 @@
use axum::{response::IntoResponse, Json};
use serde_json::json;
pub async fn healthz() -> impl IntoResponse {
Json(json!({"status": "ok"}))
}

View File

@ -1,22 +0,0 @@
use anyhow::Result;
use axum::{
routing::{get, post},
Router,
};
use crate::config::AppConfig;
pub mod deployments;
pub mod health;
pub async fn build_router(cfg: AppConfig) -> Result<Router> {
let app_state = deployments::AppState::new(cfg);
let router = Router::new()
.route("/healthz", get(health::healthz))
.route("/deployments", post(deployments::create_deployment))
.route("/deployments/schema", get(deployments::schema))
.with_state(app_state);
Ok(router)
}

View File

@ -1,20 +0,0 @@
use anyhow::Result;
use rand::{distributions::Alphanumeric, Rng};
pub struct CredentialGenerator;
impl CredentialGenerator {
pub fn random_password(length: usize) -> Result<String> {
if length < 8 {
anyhow::bail!("password length too short");
}
let password: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(length)
.map(char::from)
.collect();
Ok(password)
}
}

View File

@ -1,19 +0,0 @@
use anyhow::Result;
use crate::config::GuacamoleConfig;
#[derive(Clone)]
pub struct GuacamoleService {
config: GuacamoleConfig,
}
impl GuacamoleService {
pub fn new(config: GuacamoleConfig) -> Self {
Self { config }
}
pub async fn create_users(&self) -> Result<()> {
tracing::debug!(endpoint = %self.config.api_endpoint, "Guacamole user creation placeholder");
Ok(())
}
}

View File

@ -1,7 +0,0 @@
pub mod credentials;
pub mod guacamole;
pub mod pf_sense;
pub mod terraform;
pub mod tfvars;
pub use credentials::CredentialGenerator;

View File

@ -1,19 +0,0 @@
use anyhow::Result;
use crate::config::PfSenseConfig;
#[derive(Clone)]
pub struct PfSenseService {
config: PfSenseConfig,
}
impl PfSenseService {
pub fn new(config: PfSenseConfig) -> Self {
Self { config }
}
pub async fn push_static_mapping(&self) -> Result<()> {
tracing::debug!(endpoint = %self.config.api_endpoint, "pfSense mapping placeholder");
Ok(())
}
}

View File

@ -1,16 +0,0 @@
use anyhow::Result;
#[derive(Default, Clone)]
pub struct TerraformService;
impl TerraformService {
pub async fn apply(&self) -> Result<()> {
tracing::debug!("Terraform apply placeholder");
Ok(())
}
pub async fn destroy(&self) -> Result<()> {
tracing::debug!("Terraform destroy placeholder");
Ok(())
}
}

View File

@ -1,52 +0,0 @@
use crate::routes::deployments::DeploymentRequest;
pub struct TfvarsGenerator;
impl TfvarsGenerator {
pub fn generate(req: &DeploymentRequest) -> String {
// network is always 192.168.143.0/24 per requirements
let base_prefix = "192.168.143";
let start = req.ip_start_host.unwrap_or(50); // default to .50
let ips: Vec<String> = (0..req.user_count)
.map(|i| format!("{}.{}", base_prefix, start as u32 + i as u32))
.collect();
let mut buf = String::new();
push_kv(&mut buf, "deployment_name", &req.name);
push_kv_num(&mut buf, "instance_count", req.user_count);
push_kv(&mut buf, "vm_template", &req.vm_template);
push_kv_num(&mut buf, "cpu", req.cpu);
push_kv_num(&mut buf, "ram_mb", req.ram_mb);
push_kv_num(&mut buf, "disk_gb", req.disk_gb);
push_kv(&mut buf, "network_cidr", "192.168.143.0/24");
push_list(&mut buf, "ips", &ips);
buf
}
}
fn push_kv(buf: &mut String, key: &str, val: &str) {
use std::fmt::Write as _;
let _ = writeln!(buf, "{} = \"{}\"", key, escape(val));
}
fn push_kv_num<T: std::fmt::Display>(buf: &mut String, key: &str, val: T) {
use std::fmt::Write as _;
let _ = writeln!(buf, "{} = {}", key, val);
}
fn push_list(buf: &mut String, key: &str, values: &[String]) {
use std::fmt::Write as _;
let inner = values
.iter()
.map(|v| format!("\"{}\"", escape(v)))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(buf, "{} = [{}]", key, inner);
}
fn escape(s: &str) -> String {
s.replace('"', "\\\"")
}

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;

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

@ -0,0 +1,177 @@
"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(),
guacamole_ip: 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(),
}).transform(data => ({
...data,
guacamoleIp: data.guacamoleIp || data.guacamole_ip
}));
const guacamoleUserSchema = zod_1.z.object({
username: zod_1.z.string(),
password: zod_1.z.string(),
ip: zod_1.z.string().optional(),
displayName: zod_1.z.string().optional(),
});
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 {
logger_1.default.info(`Apply request body: ${JSON.stringify(req.body)}`);
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;

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

@ -0,0 +1,184 @@
"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"));
// Helper to extract numeric suffix for matching (e.g. "USER-1" -> 1)
const extractNumericSuffix = (s) => {
const match = s.match(/(\d+)$/);
return match ? parseInt(match[1], 10) : null;
};
const matchUserForVm = (vmName, users) => {
const vmKey = extractNumericSuffix(vmName);
if (vmKey === null)
return undefined;
return users.find(user => {
const userKey = extractNumericSuffix(user.username);
return userKey === vmKey;
});
};
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}`);
const guacamoleVms = req.vms.map(vm => {
// Try to find matching user to get specific IP (e.g. for static IP assignment via UI)
const user = matchUserForVm(vm.name, req.guacamole.users);
const targetIp = user?.ip || vm.guacamoleIp || vm.ip;
return {
name: vm.name,
ip: targetIp,
rdpDomain: vm.rdpDomain,
rdpPort: vm.rdpPort,
};
});
logger_1.default.info(`Provisioning Guacamole with VMs: ${JSON.stringify(guacamoleVms)}`);
guacamolePromise = guacamoleService.provision(formationId, req.guacamole.groupName, req.guacamole.users, guacamoleVms).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;

314
logs/all.log Normal file
View File

@ -0,0 +1,314 @@
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 18:54:45:5445"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:13:18:1318"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:20:01:201"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:27:09 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:27:09 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:27:09 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:30:02:302"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:34:37 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:34:37 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:34:37 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/login - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:54:3454"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:34:54 +0000] \"OPTIONS /auth/login HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:34:54:3454"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:35:49:3549"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:35:51:3551"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173\u001b[39m","timestamp":"2025-11-28 19:35:51:3551"}
{"level":"\u001b[33mwarn\u001b[39m","message":"\u001b[33mCORS blocked origin: http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:05 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[33mwarn\u001b[39m","message":"\u001b[33mCORS blocked origin: http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:05 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[33mwarn\u001b[39m","message":"\u001b[33mCORS blocked origin: http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:05 +0000] \"OPTIONS /auth/me HTTP/1.1\" 500 33 \"http://192.168.1.52:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:20 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:20:4420"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:20 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:20:4420"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:20 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:20:4420"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:20 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:20:4420"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"OPTIONS /auth/login HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"POST /auth/login HTTP/1.1\" 200 404 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"GET /auth/me HTTP/1.1\" 200 153 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"OPTIONS /admin/users HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"GET /admin/users HTTP/1.1\" 200 146 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"GET /admin/users HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"GET /terraform/templates HTTP/1.1\" 200 64 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:44:51 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:44:51:4451"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:02 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:02:452"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:02 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:02:452"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:02 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:02:452"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:03 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:03:453"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:03 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:03:453"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:03 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:03:453"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:22 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:22:4522"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:22 +0000] \"POST /terraform/apply HTTP/1.1\" 400 164 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:22:4522"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:42 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:42:4542"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:42 +0000] \"GET /auth/me HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:42:4542"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:42 +0000] \"GET /auth/me HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:42:4542"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:42 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:42:4542"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:42 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:42:4542"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:42 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:42:4542"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:54 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:54:4554"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:45:54 +0000] \"POST /terraform/apply HTTP/1.1\" 400 164 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:45:54:4554"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:46:36:4636"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 19:46:36:4636"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:47:14:4714"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 19:47:14:4714"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:47 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:47:5347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:47 +0000] \"GET /auth/me HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:47:5347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:47 +0000] \"GET /auth/me HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:47:5347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:47 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:47:5347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:47 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:47:5347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:47 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:47:5347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:59 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:59:5359"}
{"level":"\u001b[33mwarn\u001b[39m","message":"\u001b[33mValidation failed: [{\"expected\":\"'windows' | 'linux'\",\"received\":\"undefined\",\"code\":\"invalid_type\",\"path\":[\"vms\",0,\"model\"],\"message\":\"Required\"}]\u001b[39m","timestamp":"2025-11-28 19:53:59:5359"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:53:59 +0000] \"POST /terraform/apply HTTP/1.1\" 400 164 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:53:59:5359"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:54:07 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:54:07:547"}
{"level":"\u001b[33mwarn\u001b[39m","message":"\u001b[33mValidation failed: [{\"expected\":\"'windows' | 'linux'\",\"received\":\"undefined\",\"code\":\"invalid_type\",\"path\":[\"vms\",0,\"model\"],\"message\":\"Required\"}]\u001b[39m","timestamp":"2025-11-28 19:54:07:547"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:18:54:07 +0000] \"POST /terraform/apply HTTP/1.1\" 400 164 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 19:54:07:547"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 19:55:01:551"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 19:55:01:551"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:14 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:14 +0000] \"POST /terraform/apply HTTP/1.1\" 202 76 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:14 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:14 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:14 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mApply job failed for test: Error: Formation already exists on terraform host\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:26 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:26:126"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:26 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:26:126"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:27 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:27:127"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:27 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:27:127"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:28 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:28:128"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:31 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:31:131"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:31 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:31:131"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:01:31 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:01:31:131"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mReceived kill signal, shutting down gracefully\u001b[39m","timestamp":"2025-11-28 20:01:37:137"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mClosed out remaining connections\u001b[39m","timestamp":"2025-11-28 20:01:37:137"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mDatabase connection closed\u001b[39m","timestamp":"2025-11-28 20:01:37:137"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:01:41:141"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:01:41:141"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:02:19:219"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:02:19:219"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:03:31 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:03:31:331"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:03:31 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:03:31:331"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:03:32 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:03:32:332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:03:46 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:03:46:346"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:03:46 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:03:46:346"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:03:46 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:03:46:346"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:05 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:05:45"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:05 +0000] \"POST /terraform/apply HTTP/1.1\" 202 80 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:05:45"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:05 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:05:45"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:05 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:05:45"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:05 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:05:45"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:14 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:14:414"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:04:14 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:04:14:414"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation test50\u001b[39m","timestamp":"2025-11-28 20:05:46:546"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation test50: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:05:46:546"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:06:00 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:06:00:60"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:06:00 +0000] \"GET /terraform/formations HTTP/1.1\" 200 197 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:06:00:60"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:06:05 +0000] \"OPTIONS /terraform/formations/undefined HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:06:05:65"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:06:05 +0000] \"DELETE /terraform/formations/undefined HTTP/1.1\" 404 33 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:06:05:65"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:06:35:635"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:06:35:635"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:09:10:910"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:09:10:910"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:09:12:912"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:09:12:912"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:11:26 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:11:26:1126"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:11:26 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:11:26:1126"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:11:28 +0000] \"OPTIONS /terraform/formations/undefined HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:11:28:1128"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:11:28 +0000] \"DELETE /terraform/formations/undefined HTTP/1.1\" 404 33 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:11:28:1128"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:11:31 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:11:31:1131"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:12:31:1231"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:12:31:1231"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:14:41 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:14:41:1441"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:14:41 +0000] \"GET /terraform/formations HTTP/1.1\" 200 192 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:14:41:1441"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:14:45 +0000] \"OPTIONS /terraform/formations/test50 HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:14:45:1445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:14:57 +0000] \"DELETE /terraform/formations/test50 HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:14:57:1457"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for test50: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 20:14:57:1457"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:15:27 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:15:27:1527"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:15:28 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:15:28:1528"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:15:28 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:15:28:1528"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:16:03 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:16:03:163"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:16:03 +0000] \"POST /terraform/apply HTTP/1.1\" 202 76 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:16:03:163"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:16:03 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:16:03:163"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:16:03 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:16:03:163"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:16:03 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:16:03:163"}
{"level":"\u001b[33mwarn\u001b[39m","message":"\u001b[33mRemote directory /home/corenthin/test exists but formation is not in DB. Cleaning up stale directory.\u001b[39m","timestamp":"2025-11-28 20:16:04:164"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation test\u001b[39m","timestamp":"2025-11-28 20:17:42:1742"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation test: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:17:42:1742"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:09 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:09:239"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:09 +0000] \"GET /terraform/formations HTTP/1.1\" 200 189 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:09:239"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:18 +0000] \"OPTIONS /terraform/formations/test HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:18:2318"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:32 +0000] \"DELETE /terraform/formations/test HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:32:2332"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for test: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 20:23:32:2332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:48 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:48:2348"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:48 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:48:2348"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:23:48 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:23:48:2348"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:24:07 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:24:07:247"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:24:07 +0000] \"POST /terraform/apply HTTP/1.1\" 202 86 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:24:07:247"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:24:07 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:24:07:247"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:24:07 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:24:07:247"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:24:07 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:24:07:247"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation corenthin\u001b[39m","timestamp":"2025-11-28 20:25:46:2546"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation corenthin: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:25:46:2546"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:26:46:2646"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:26:46:2646"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:18 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:18:3018"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:18 +0000] \"GET /terraform/formations HTTP/1.1\" 200 201 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:18:3018"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:21 +0000] \"OPTIONS /terraform/formations/corenthin HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:21:3021"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:33 +0000] \"DELETE /terraform/formations/corenthin HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:33:3033"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for corenthin: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 20:30:33:3033"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:51 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:51:3051"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:51 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:51:3051"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:30:51 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:30:51:3051"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:31:04 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:31:04:314"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:31:04 +0000] \"POST /terraform/apply HTTP/1.1\" 202 74 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:31:04:314"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:31:04 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:31:04:314"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:31:04 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:31:04:314"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:31:04 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:31:04:314"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:31:08 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:31:08:318"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation oui\u001b[39m","timestamp":"2025-11-28 20:32:43:3243"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation oui: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:32:43:3243"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:34:07:347"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:34:07:347"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:29 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:29:3729"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:29 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:29:3729"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:29 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:29:3729"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:48 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:48:3748"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:48 +0000] \"POST /terraform/apply HTTP/1.1\" 202 80 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:48:3748"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:48 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:48:3748"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:48 +0000] \"GET /terraform/formations HTTP/1.1\" 200 184 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:48:3748"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:37:48 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:37:48:3748"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation blabla\u001b[39m","timestamp":"2025-11-28 20:39:26:3926"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation blabla: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mFailed request: PATCH https://guacamole.firewax.fr/guacamole/api/session/data/mysql/userGroups/blabla/memberUsers\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse status: 415\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse data: \"<!doctype html><html lang=\\\"fr\\\"><head><title>État HTTP 415 Type de média non supporté</title><style type=\\\"text/css\\\">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>État HTTP 415 Type de média non supporté</h1><hr class=\\\"line\\\" /><p><b>Type</b> Rapport d'état</p><p><b>message</b> Unsupported Media Type</p><p><b>description</b> Le serveur a refusé cette requête car l'entité de requête est dans un format non supporté par la ressource demandée avec la méthode spécifiée.</p><hr class=\\\"line\\\" /><h3>Apache Tomcat/9.0.70 (Debian)</h3></body></html>\"\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:41:10:4110"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:41:10:4110"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:41:15:4115"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:41:15:4115"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 20:41:19:4119"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 20:41:19:4119"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:32 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:32:4332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:32 +0000] \"GET /auth/me HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:32:4332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:32 +0000] \"GET /auth/me HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:32:4332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:32 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:32:4332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:32 +0000] \"GET /terraform/formations HTTP/1.1\" 200 360 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:32:4332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:32 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:32:4332"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:39 +0000] \"OPTIONS /terraform/formations/oui HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:39:4339"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:44 +0000] \"OPTIONS /terraform/formations/blabla HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:44:4344"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:52 +0000] \"DELETE /terraform/formations/oui HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:52:4352"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:43:57 +0000] \"DELETE /terraform/formations/blabla HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:43:57:4357"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:31 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:31:4431"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:31 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:31:4431"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:31 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:31:4431"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:46 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:46:4446"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:46 +0000] \"POST /terraform/apply HTTP/1.1\" 202 74 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:46:4446"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:46 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:46:4446"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:46 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:46:4446"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:19:44:46 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 20:44:46:4446"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation mat\u001b[39m","timestamp":"2025-11-28 20:46:24:4624"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation mat: Connection for VM \"VM-1\" already exists.\u001b[39m","timestamp":"2025-11-28 20:46:25:4625"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:48:23 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:48:23:4823"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:48:23 +0000] \"GET /terraform/formations HTTP/1.1\" 200 183 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:48:23:4823"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:50:02 +0000] \"OPTIONS /terraform/formations/mat HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:50:02:502"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:50:14 +0000] \"DELETE /terraform/formations/mat HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:50:14:5014"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for mat: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 21:50:14:5014"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 21:51:57:5157"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 21:51:57:5157"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 21:54:18:5418"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 21:54:18:5418"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:26 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:26:5526"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:26 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:26:5526"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:26 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:26:5526"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:44 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:44:5544"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:44 +0000] \"POST /terraform/apply HTTP/1.1\" 202 74 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:44:5544"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:44 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:44:5544"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:44 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:44:5544"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:55:44 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:55:44:5544"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation leo\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation leo: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mFailed request: PATCH https://guacamole.firewax.fr/guacamole/api/session/data/mysql/userGroups/LEO/memberUsers\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mRequest headers: {\"Accept\":\"application/json, text/plain, */*\",\"Content-Type\":\"application/json-patch+json\",\"User-Agent\":\"axios/1.13.2\",\"Content-Length\":\"41\",\"Accept-Encoding\":\"gzip, compress, deflate, br\"}\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse status: 415\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse data: \"<!doctype html><html lang=\\\"fr\\\"><head><title>État HTTP 415 Type de média non supporté</title><style type=\\\"text/css\\\">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>État HTTP 415 Type de média non supporté</h1><hr class=\\\"line\\\" /><p><b>Type</b> Rapport d'état</p><p><b>message</b> Unsupported Media Type</p><p><b>description</b> Le serveur a refusé cette requête car l'entité de requête est dans un format non supporté par la ressource demandée avec la méthode spécifiée.</p><hr class=\\\"line\\\" /><h3>Apache Tomcat/9.0.70 (Debian)</h3></body></html>\"\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:57:42 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:57:42:5742"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:57:42 +0000] \"GET /terraform/formations HTTP/1.1\" 200 183 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:57:42:5742"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 21:58:25:5825"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 21:58:25:5825"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mServer running on port 3000\u001b[39m","timestamp":"2025-11-28 21:58:29:5829"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCORS Allowed Origins: http://localhost:3000, http://localhost:5173, http://127.0.0.1:3000, http://127.0.0.1:5173, http://192.168.1.52:5173\u001b[39m","timestamp":"2025-11-28 21:58:29:5829"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:59:16 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:59:16:5916"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:59:16 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:59:16:5916"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:59:18 +0000] \"OPTIONS /terraform/formations/leo HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:59:18:5918"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:20:59:30 +0000] \"DELETE /terraform/formations/leo HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 21:59:30:5930"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCleaning up Guacamole for group LEO\u001b[39m","timestamp":"2025-11-28 21:59:30:5930"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mFound 0 users and 0 connections to delete for group LEO\u001b[39m","timestamp":"2025-11-28 21:59:30:5930"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mGuacamole cleanup completed for LEO\u001b[39m","timestamp":"2025-11-28 21:59:30:5930"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:01 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:01:01"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:01 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:01:01"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:01 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:01:01"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:16 +0000] \"OPTIONS /terraform/apply HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:16:016"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:16 +0000] \"POST /terraform/apply HTTP/1.1\" 202 76 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:16:016"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:16 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:16:016"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:16 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:16:016"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:00:16 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:00:16:016"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mStarting Guacamole provisioning for formation quac\u001b[39m","timestamp":"2025-11-28 22:01:56:156"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mGuacamole provisioning succeeded for formation quac\u001b[39m","timestamp":"2025-11-28 22:01:57:157"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:03:43 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:03:43:343"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:03:43 +0000] \"GET /terraform/formations HTTP/1.1\" 200 186 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:03:43:343"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:10 +0000] \"OPTIONS /terraform/formations/quac HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:10:410"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:22 +0000] \"DELETE /terraform/formations/quac HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:22:422"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mCleaning up Guacamole for group quac\u001b[39m","timestamp":"2025-11-28 22:04:22:422"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mFound 1 users and 1 connections to delete for group quac\u001b[39m","timestamp":"2025-11-28 22:04:22:422"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mGuacamole cleanup completed for quac\u001b[39m","timestamp":"2025-11-28 22:04:22:422"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:26 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:26:426"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:26 +0000] \"OPTIONS /admin/users HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:26:426"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:26 +0000] \"GET /admin/users HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:26:426"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:26 +0000] \"GET /admin/users HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:26:426"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:26 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:26:426"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:26 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:26:426"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:29 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:29:429"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:29 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:29:429"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:29 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:29:429"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:29 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:29:429"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:29 +0000] \"GET /auth/me HTTP/1.1\" 401 29 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:29:429"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"OPTIONS /auth/login HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"POST /auth/login HTTP/1.1\" 200 404 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"OPTIONS /auth/me HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"GET /auth/me HTTP/1.1\" 200 153 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"OPTIONS /admin/users HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"OPTIONS /admin/users HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"GET /admin/users HTTP/1.1\" 200 146 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"GET /admin/users HTTP/1.1\" 200 146 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:04:45 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:04:45:445"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:03 +0000] \"OPTIONS /admin/users HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:03:53"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:03 +0000] \"POST /admin/users HTTP/1.1\" 201 123 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:03:53"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:11 +0000] \"OPTIONS /admin/users/7bfd551e-d15f-466b-80e0-dc8e13ab4c7d HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:11:511"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:11 +0000] \"PUT /admin/users/7bfd551e-d15f-466b-80e0-dc8e13ab4c7d HTTP/1.1\" 200 124 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:11:511"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:12 +0000] \"OPTIONS /admin/users/8a583ec1-cc42-11f0-8ab1-a63e2675a89e HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:12:512"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:12 +0000] \"PUT /admin/users/8a583ec1-cc42-11f0-8ab1-a63e2675a89e HTTP/1.1\" 400 56 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:12:512"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:17 +0000] \"DELETE /admin/users/8a583ec1-cc42-11f0-8ab1-a63e2675a89e HTTP/1.1\" 400 36 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:17:517"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:19 +0000] \"OPTIONS /admin/users/7bfd551e-d15f-466b-80e0-dc8e13ab4c7d HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:19:519"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:19 +0000] \"DELETE /admin/users/7bfd551e-d15f-466b-80e0-dc8e13ab4c7d HTTP/1.1\" 204 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:19:519"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:29 +0000] \"OPTIONS /terraform/templates HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:29:529"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:29 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:29:529"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:29 +0000] \"GET /terraform/templates HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:29:529"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:32 +0000] \"OPTIONS /terraform/formations HTTP/1.1\" 204 0 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:32:532"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:32 +0000] \"GET /terraform/formations HTTP/1.1\" 200 17 \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:32:532"}
{"level":"\u001b[35mhttp\u001b[39m","message":"\u001b[35m::ffff:127.0.0.1 - - [28/Nov/2025:21:05:32 +0000] \"GET /terraform/formations HTTP/1.1\" 304 - \"http://localhost:5173/\" \"Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0\"\u001b[39m","timestamp":"2025-11-28 22:05:32:532"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mReceived kill signal, shutting down gracefully\u001b[39m","timestamp":"2025-11-28 22:05:47:547"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mClosed out remaining connections\u001b[39m","timestamp":"2025-11-28 22:05:47:547"}
{"level":"\u001b[32minfo\u001b[39m","message":"\u001b[32mDatabase connection closed\u001b[39m","timestamp":"2025-11-28 22:05:47:547"}

29
logs/error.log Normal file
View File

@ -0,0 +1,29 @@
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:27:09:279"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:37:3437"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/login - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:34:54:3454"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31m500 - Not allowed by CORS - /auth/me - OPTIONS - ::ffff:127.0.0.1\u001b[39m","timestamp":"2025-11-28 19:44:05:445"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mApply job failed for test: Error: Formation already exists on terraform host\u001b[39m","timestamp":"2025-11-28 20:01:14:114"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation test50: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:05:46:546"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for test50: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 20:14:57:1457"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation test: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:17:42:1742"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for test: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 20:23:32:2332"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation corenthin: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:25:46:2546"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for corenthin: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 20:30:33:3033"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation oui: AxiosError: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:32:43:3243"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation blabla: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mFailed request: PATCH https://guacamole.firewax.fr/guacamole/api/session/data/mysql/userGroups/blabla/memberUsers\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse status: 415\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse data: \"<!doctype html><html lang=\\\"fr\\\"><head><title>État HTTP 415 Type de média non supporté</title><style type=\\\"text/css\\\">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>État HTTP 415 Type de média non supporté</h1><hr class=\\\"line\\\" /><p><b>Type</b> Rapport d'état</p><p><b>message</b> Unsupported Media Type</p><p><b>description</b> Le serveur a refusé cette requête car l'entité de requête est dans un format non supporté par la ressource demandée avec la méthode spécifiée.</p><hr class=\\\"line\\\" /><h3>Apache Tomcat/9.0.70 (Debian)</h3></body></html>\"\u001b[39m","timestamp":"2025-11-28 20:39:27:3927"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation mat: Connection for VM \"VM-1\" already exists.\u001b[39m","timestamp":"2025-11-28 20:46:25:4625"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole cleanup failed for mat: AxiosError: Request failed with status code 404\u001b[39m","timestamp":"2025-11-28 21:50:14:5014"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mGuacamole provisioning failed for formation leo: Request failed with status code 415\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mFailed request: PATCH https://guacamole.firewax.fr/guacamole/api/session/data/mysql/userGroups/LEO/memberUsers\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mRequest headers: {\"Accept\":\"application/json, text/plain, */*\",\"Content-Type\":\"application/json-patch+json\",\"User-Agent\":\"axios/1.13.2\",\"Content-Length\":\"41\",\"Accept-Encoding\":\"gzip, compress, deflate, br\"}\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse status: 415\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}
{"level":"\u001b[31merror\u001b[39m","message":"\u001b[31mResponse data: \"<!doctype html><html lang=\\\"fr\\\"><head><title>État HTTP 415 Type de média non supporté</title><style type=\\\"text/css\\\">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>État HTTP 415 Type de média non supporté</h1><hr class=\\\"line\\\" /><p><b>Type</b> Rapport d'état</p><p><b>message</b> Unsupported Media Type</p><p><b>description</b> Le serveur a refusé cette requête car l'entité de requête est dans un format non supporté par la ressource demandée avec la méthode spécifiée.</p><hr class=\\\"line\\\" /><h3>Apache Tomcat/9.0.70 (Debian)</h3></body></html>\"\u001b[39m","timestamp":"2025-11-28 21:57:23:5723"}

2570
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "api-ts",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "5.22.0",
"argon2": "^0.44.0",
"axios": "^1.13.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"prisma": "5.22.0",
"ssh2": "^1.17.0",
"uuid": "^13.0.0",
"winston": "^3.18.3",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/node": "^24.10.1",
"@types/ssh2": "^1.15.5",
"@types/uuid": "^10.0.0",
"nodemon": "^3.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

14
prisma.config.ts.bak Normal file
View File

@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

27
prisma/schema.prisma Normal file
View File

@ -0,0 +1,27 @@
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model terraform_formations {
id String @id @db.Char(36)
formation_id String @unique(map: "uk_terraform_formations_formation") @db.VarChar(255)
remote_path String @db.VarChar(1024)
guacamole_group_name String? @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
}
model users {
id String @id @db.Char(36)
email String @unique(map: "uk_users_email") @db.VarChar(255)
password_hash String @db.Text
display_name String? @db.VarChar(255)
is_admin Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamp(0)
}

75
src/config/index.ts Normal file
View File

@ -0,0 +1,75 @@
import dotenv from 'dotenv';
import { z } from 'zod';
dotenv.config();
const envSchema = z.object({
PORT: z.string().default('3000'),
DATABASE_URL: z.string(),
JWT_SECRET: z.string(),
JWT_TTL_SECONDS: z.string().default('3600'),
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'),
// 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_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'),
// 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_INSECURE_TLS: z.string().default('true'),
PROXMOX_TARGET_NODE: z.string().default('node1'),
PROXMOX_MODEL_WINDOWS: z.string().default('e1000'),
PROXMOX_MODEL_LINUX: z.string().default('virtio'),
// Cors
CORS_ALLOWED_ORIGINS: 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);
export 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(','),
}
};

View File

@ -0,0 +1,196 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';
import prisma from '../utils/prisma';
import * as authService from '../services/authService';
import config from '../config';
import logger from '../utils/logger';
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().optional(),
isAdmin: z.boolean().default(false),
});
const updateUserSchema = z.object({
email: z.string().email().optional(),
password: z.string().min(8).optional(),
displayName: z.string().optional().nullable(),
isAdmin: z.boolean().optional(),
});
const mapUser = (user: any) => ({
id: user.id,
email: user.email,
name: user.display_name || user.email,
displayName: user.display_name,
isAdmin: user.is_admin,
});
export const getDashboard = async (req: Request, res: Response) => {
try {
const users = await prisma.users.findMany();
const summaries = users.map(mapUser);
const currentUser = await prisma.users.findUnique({ where: { id: req.user!.id } });
res.json({
currentUser: mapUser(currentUser),
users: summaries,
});
} catch (error) {
logger.error(`Dashboard error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const listUsers = async (req: Request, res: Response) => {
try {
const users = await prisma.users.findMany();
const summaries = users.map(mapUser);
res.json(summaries);
} catch (error) {
logger.error(`List users error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const createUser = async (req: Request, res: Response): Promise<void> => {
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.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.users.create({
data: {
id: uuidv4(),
email,
password_hash: passwordHash,
display_name: displayName,
is_admin: isAdmin,
},
});
res.status(201).json(mapUser(newUser));
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
logger.error(`Create user error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const updateUser = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { email, password, displayName, isAdmin } = updateUserSchema.parse(req.body);
const currentUser = req.user!;
const userToUpdate = await prisma.users.findUnique({ where: { id } });
if (!userToUpdate) {
res.status(404).json({ message: 'User not found' });
return;
}
const defaultAdminEmail = config.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: any = {};
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.users.update({
where: { id },
data: updateData,
});
res.json(mapUser(updatedUser));
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
// Handle unique constraint violation for email
// Prisma throws P2002
if ((error as any).code === 'P2002') {
res.status(409).json({ message: 'Email already registered' });
return;
}
logger.error(`Update user error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const deleteUser = async (req: Request, res: Response): Promise<void> => {
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.users.findUnique({ where: { id } });
if (!userToDelete) {
res.status(404).json({ message: 'User not found' });
return;
}
const defaultAdminEmail = config.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.users.delete({ where: { id } });
res.status(204).send();
} catch (error) {
logger.error(`Delete user error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};

View File

@ -0,0 +1,104 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../utils/prisma';
import * as authService from '../services/authService';
import authGuard from '../services/bruteforceService';
import { setSessionCookie, clearSessionCookie } from '../utils/helpers';
import logger from '../utils/logger';
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export const login = async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = loginSchema.parse(req.body);
if (authGuard.isLocked(email)) {
res.status(429).json({ message: 'Too many attempts, temporarily locked' });
return;
}
const user = await prisma.users.findUnique({ where: { email } });
if (!user) {
authGuard.recordFailure(email);
res.status(401).json({ message: 'Invalid credentials' });
return;
}
const isValid = await authService.verifyPassword(password, user.password_hash);
if (!isValid) {
authGuard.recordFailure(email);
res.status(401).json({ message: 'Invalid credentials' });
return;
}
authGuard.recordSuccess(email);
const token = authService.signToken(user.id, user.email);
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 z.ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
logger.error(`Login error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const me = async (req: Request, res: Response): Promise<void> => {
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.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.error(`Me error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const logout = async (req: Request, res: Response): Promise<void> => {
clearSessionCookie(res);
res.status(204).send();
};

View File

@ -0,0 +1,60 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs/promises';
import path from 'path';
import config from '../config';
import * as tfvarsService from '../services/tfvarsService';
import { zodToJsonSchema } from 'zod-to-json-schema';
import logger from '../utils/logger';
const deploymentSchema = z.object({
name: z.string().min(1),
userCount: z.number().int().min(1),
vmTemplate: z.string(),
cpu: z.number().int().min(1),
ramMb: z.number().int().min(256),
diskGb: z.number().int().min(10),
ipStartHost: z.number().int().min(2).max(254).optional(),
});
export const createDeployment = async (req: Request, res: Response): Promise<void> => {
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 = uuidv4();
const tfvarsContent = tfvarsService.generate(payload);
const dropDir = config.terraform.dropDir;
const filename = `${payload.name.replace(/ /g, '_')}.tfvars`;
const filePath = path.join(dropDir, filename);
await fs.mkdir(dropDir, { recursive: true });
await fs.writeFile(filePath, tfvarsContent);
res.json({
id,
tfvarsPath: filePath,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: 'Invalid input', details: error.issues });
return;
}
logger.error(`Create deployment error: ${error}`);
res.status(500).json({ error: 'Internal server error' });
}
};
export const getSchema = async (req: Request, res: Response) => {
const schema = zodToJsonSchema(deploymentSchema as any, 'DeploymentRequest');
res.json(schema);
};

View File

@ -0,0 +1,153 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import prisma from '../utils/prisma';
import config from '../config';
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({
name: z.string(),
vmid: z.number(),
cores: z.number(),
memory: z.number(),
disk_size: z.number(),
template: z.string(),
ip: z.string(),
guacamoleIp: z.string().optional(),
guacamole_ip: z.string().optional(),
bridge: z.string().optional(),
model: z.enum(['windows', 'linux']).default('linux'),
rdpDomain: z.string().optional(),
rdpPort: z.number().optional(),
}).transform(data => ({
...data,
guacamoleIp: data.guacamoleIp || data.guacamole_ip
}));
const guacamoleUserSchema = z.object({
username: z.string(),
password: z.string(),
ip: z.string().optional(),
displayName: z.string().optional(),
});
const guacamoleRequestSchema = z.object({
groupName: z.string().optional(),
users: z.array(guacamoleUserSchema),
});
const applySchema = z.object({
formationId: z.string().min(1),
vms: z.array(vmSchema).min(1),
guacamole: guacamoleRequestSchema.optional(),
});
// Helper to clean formation ID
const cleanFormationId = (id: string): string => {
return id;
};
const isValidFormationId = (id: string): boolean => {
return /^[a-zA-Z0-9-_]+$/.test(id);
};
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);
if (!isValidFormationId(formationId)) {
res.status(400).json({ message: 'Invalid formation id' });
return;
}
const existingFormation = await prisma.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 as unknown as ApplyRequest).catch(err => {
logger.error(`Apply job failed for ${formationId}: ${err}`);
});
const remoteDirPreview = path.join(config.terraform.deployBasePath, formationId);
res.status(202).json({
status: 'queued',
formationId,
remotePath: remoteDirPreview,
});
} catch (error) {
if (error instanceof z.ZodError) {
logger.warn(`Validation failed: ${JSON.stringify(error.issues)}`);
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
logger.error(`Apply error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const getTemplates = async (req: Request, res: Response) => {
try {
const templates = await proxmoxService.fetchTemplates();
res.json({ templates });
} catch (error) {
logger.error(`Get templates error: ${error}`);
res.status(502).json({ message: 'Failed to fetch templates from proxmox' });
}
};
export const listFormations = async (req: Request, res: Response) => {
try {
const formationsRaw = await prisma.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.error(`List formations error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};
export const destroyFormation = async (req: Request, res: Response): Promise<void> => {
try {
const { formationId } = req.params;
const formation = await prisma.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.error(`Background destroy failed for ${formationId}: ${err}`);
});
res.status(202).json({ message: 'Formation destruction queued' });
} catch (error: any) {
logger.error(`Destroy formation error: ${error}`);
res.status(500).json({ message: 'Internal server error' });
}
};

76
src/index.ts Normal file
View File

@ -0,0 +1,76 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import morgan from 'morgan';
import config from './config';
import routes from './routes';
import logger from './utils/logger';
import { errorHandler } from './middlewares/errorHandler';
import prisma from './utils/prisma';
const app = express();
// Trust the first proxy (Nginx/Traefik)
app.set('trust proxy', 1);
// HTTP Request Logging
app.use(morgan('combined', { stream: { write: (message) => logger.http(message.trim()) } }));
app.use(helmet());
app.use(cookieParser());
app.use(express.json());
// Rate Limiting
const limiter = rateLimit({
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(cors({
origin: (origin, callback) => {
if (!origin || config.cors.allowedOrigins.includes(origin)) {
callback(null, true);
} else {
logger.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);
// Error Handling Middleware (must be last)
app.use(errorHandler);
const server = app.listen(config.port, () => {
logger.info(`Server running on port ${config.port}`);
logger.info(`CORS Allowed Origins: ${config.cors.allowedOrigins.join(', ')}`);
});
// Graceful Shutdown
const gracefulShutdown = async () => {
logger.info('Received kill signal, shutting down gracefully');
server.close(() => {
logger.info('Closed out remaining connections');
prisma.$disconnect().then(() => {
logger.info('Database connection closed');
process.exit(0);
});
});
setTimeout(() => {
logger.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

View File

@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from 'express';
import * as authService from '../services/authService';
import prisma from '../utils/prisma';
// Extend Express Request to include user
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
isAdmin: boolean;
};
}
}
}
export const authenticate = async (req: Request, res: Response, next: NextFunction) => {
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.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' });
}
};
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !req.user.isAdmin) {
res.status(403).json({ message: 'Admin privileges required' });
return;
}
next();
};

View File

@ -0,0 +1,23 @@
import { Request, Response, NextFunction } from 'express';
import logger from '../utils/logger';
import { z } from 'zod';
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
logger.error(`${err.status || 500} - ${err.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`);
if (err instanceof 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 }),
});
};

15
src/routes/admin.ts Normal file
View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import * as adminController from '../controllers/adminController';
import { authenticate, requireAdmin } from '../middlewares/authMiddleware';
const router = Router();
router.use(authenticate, 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);
export default router;

10
src/routes/auth.ts Normal file
View File

@ -0,0 +1,10 @@
import { Router } from 'express';
import * as authController from '../controllers/authController';
const router = Router();
router.post('/login', authController.login);
router.get('/me', authController.me);
router.post('/logout', authController.logout);
export default router;

View File

@ -0,0 +1,9 @@
import { Router } from 'express';
import * as deploymentsController from '../controllers/deploymentsController';
const router = Router();
router.post('/', deploymentsController.createDeployment);
router.get('/schema', deploymentsController.getSchema);
export default router;

27
src/routes/index.ts Normal file
View File

@ -0,0 +1,27 @@
import { Router } from 'express';
import authRoutes from './auth';
import deploymentsRoutes from './deployments';
import adminRoutes from './admin';
import terraformRoutes from './terraform';
const router = Router();
import prisma from '../utils/prisma';
import logger from '../utils/logger';
router.get('/healthz', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.status(200).json({ status: 'ok', database: 'connected' });
} catch (error) {
logger.error(`Health check failed: ${error}`);
res.status(503).json({ status: 'error', database: 'disconnected' });
}
});
router.use('/auth', authRoutes);
router.use('/deployments', deploymentsRoutes);
router.use('/admin', adminRoutes);
router.use('/terraform', terraformRoutes);
export default router;

11
src/routes/terraform.ts Normal file
View File

@ -0,0 +1,11 @@
import { Router } from 'express';
import * as terraformController from '../controllers/terraformController';
const router = Router();
router.post('/apply', terraformController.apply);
router.get('/templates', terraformController.getTemplates);
router.get('/formations', terraformController.listFormations);
router.delete('/formations/:formationId', terraformController.destroyFormation);
export default router;

View File

@ -0,0 +1,40 @@
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import config from '../config';
export const hashPassword = async (password: string): Promise<string> => {
return await argon2.hash(password);
};
export const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
try {
return await argon2.verify(hash, password);
} catch (err) {
return false;
}
};
export interface TokenClaims {
userId: string;
email: string;
}
export const signToken = (userId: string, email: string): string => {
return jwt.sign(
{ sub: userId, email },
config.jwt.secret,
{ expiresIn: config.jwt.ttlSeconds }
);
};
export const verifyToken = (token: string): TokenClaims | null => {
try {
const decoded = jwt.verify(token, config.jwt.secret) as any;
return {
userId: decoded.sub,
email: decoded.email,
};
} catch (err) {
return null;
}
};

View File

@ -0,0 +1,43 @@
interface Attempt {
count: number;
lockUntil: number | null; // Timestamp in milliseconds
}
class AuthGuard {
private attempts: Map<string, Attempt> = new Map();
public isLocked(key: string): boolean {
const attempt = this.attempts.get(key);
if (attempt && attempt.lockUntil) {
return Date.now() < attempt.lockUntil;
}
return false;
}
public recordFailure(key: string, maxAttempts: number = 5, lockoutSecs: number = 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
}
}
public recordSuccess(key: string) {
this.attempts.delete(key);
}
}
export default new AuthGuard();

View File

@ -0,0 +1,88 @@
import { GuacSession, GuacamoleUser, GuacamoleVm } from '../utils/guacamoleClient';
import logger from '../utils/logger';
// Helper to extract numeric suffix for matching
const extractNumericSuffix = (s: string): number | null => {
const match = s.match(/(\d+)$/);
return match ? parseInt(match[1], 10) : null;
};
const matchUserForVm = (vm: GuacamoleVm, users: GuacamoleUser[]): GuacamoleUser | undefined => {
const vmKey = extractNumericSuffix(vm.name);
if (vmKey === null) return undefined;
return users.find(user => {
const userKey = extractNumericSuffix(user.username);
return userKey === vmKey;
});
};
export const provision = async (formationId: string, groupName: string | undefined, users: GuacamoleUser[], vms: GuacamoleVm[]) => {
if (users.length === 0) return;
const session = await 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);
};
export const deleteProvisioning = async (formationId: string, groupName?: string) => {
const session = await GuacSession.login();
const groupIdentifier = groupName || `formation-${formationId}`;
try {
logger.info(`Cleaning up Guacamole for group ${groupIdentifier}`);
const { users, connections } = await session.getGroupMembers(groupIdentifier);
logger.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.info(`Guacamole cleanup completed for ${groupIdentifier}`);
} catch (error: any) {
logger.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
}
};

View File

@ -0,0 +1,52 @@
import axios from 'axios';
import https from 'https';
import config from '../config';
interface ProxmoxTemplate {
vmid: number;
name: string;
diskSizeGb: number;
}
interface QemuVm {
vmid?: number;
name?: string;
template?: number;
maxdisk?: number;
}
interface QemuListResponse {
data: QemuVm[];
}
export const fetchTemplates = async (): Promise<ProxmoxTemplate[]> => {
const { url, targetNode, tokenId, tokenSecret, insecureTls } = config.proxmox;
const apiUrl = `${url.replace(/\/$/, '')}/nodes/${targetNode}/qemu?full=1`;
const agent = new https.Agent({
rejectUnauthorized: !insecureTls
});
try {
const response = await axios.get<QemuListResponse>(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');
}
};

View File

@ -0,0 +1,173 @@
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import prisma from '../utils/prisma';
import config from '../config';
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';
// Helper to extract numeric suffix for matching (e.g. "USER-1" -> 1)
const extractNumericSuffix = (s: string): number | null => {
const match = s.match(/(\d+)$/);
return match ? parseInt(match[1], 10) : null;
};
const matchUserForVm = (vmName: string, users: any[]): any | undefined => {
const vmKey = extractNumericSuffix(vmName);
if (vmKey === null) return undefined;
return users.find(user => {
const userKey = extractNumericSuffix(user.username);
return userKey === vmKey;
});
};
const prepareRemoteWorkspace = async (formationId: string): Promise<string> => {
const remoteDir = path.posix.join(config.terraform.deployBasePath, formationId);
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);
if (checkRes.code === 0) {
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)}`;
const copyRes = await shell.executeSsh(copyCmd);
if (copyRes.code !== 0) {
throw new Error(`Remote copy failed: ${copyRes.stderr}`);
}
};
const cleanupRemote = async (remoteDir: string) => {
const cmd = `rm -rf ${shell.escapeShell(remoteDir)}`;
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);
try {
const tfvars = tfvarsService.generateVmDefinitions(req as any);
// Upload tfvars
// We use path.posix.join to ensure forward slashes for remote linux path
const uploadRes = await shell.uploadFileSsh(path.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<void> = Promise.resolve();
if (req.guacamole) {
logger.info(`Starting Guacamole provisioning for formation ${formationId}`);
const guacamoleVms = req.vms.map(vm => {
// Try to find matching user to get specific IP (e.g. for static IP assignment via UI)
const user = matchUserForVm(vm.name, req.guacamole!.users);
const targetIp = user?.ip || vm.guacamoleIp || vm.ip;
return {
name: vm.name,
ip: targetIp,
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,
guacamoleVms
).then(() => {
logger.info(`Guacamole provisioning succeeded for formation ${formationId}`);
}).catch((error: any) => {
logger.error(`Guacamole provisioning failed for formation ${formationId}: ${error.message}`);
if (error.config) logger.error(`Failed request: ${error.config.method?.toUpperCase()} ${error.config.url}`);
if (error.response) logger.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.terraform_formations.create({
data: {
id: uuidv4(),
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;
}
};
export const destroyFormation = async (formationId: string) => {
const formation = await prisma.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.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.error(`Guacamole cleanup failed for ${formation.formation_id}: ${err}`));
}
};

View File

@ -0,0 +1,98 @@
import config from '../config';
import { VmDefinition, ApplyRequest } from '../types/terraform';
interface DeploymentRequest {
name: string;
userCount: number;
vmTemplate: string;
cpu: number;
ramMb: number;
diskGb: number;
ipStartHost?: number;
}
const escape = (s: string) => s.replace(/"/g, '\\"');
export const generate = (req: DeploymentRequest): string => {
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;
};
export const generateVmDefinitions = (req: ApplyRequest): string => {
const cfg = config.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;
};
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;
};

25
src/types/terraform.ts Normal file
View File

@ -0,0 +1,25 @@
export interface VmDefinition {
name: string;
vmid: number;
cores: number;
memory: number;
disk_size: number;
template: string;
ip: string;
guacamoleIp?: string;
bridge?: string;
model: 'windows' | 'linux';
rdpDomain?: string;
rdpPort?: number;
}
export interface GuacamoleConfig {
groupName?: string;
users: { username: string; password: string; ip?: string; displayName?: string }[];
}
export interface ApplyRequest {
formationId: string;
vms: VmDefinition[];
guacamole?: GuacamoleConfig;
}

View 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 });
}
}

20
src/utils/helpers.ts Normal file
View File

@ -0,0 +1,20 @@
import { Response } from 'express';
export const setSessionCookie = (res: Response, token: string) => {
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: '/'
});
};
export const clearSessionCookie = (res: Response) => {
res.clearCookie('token', {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: 'lax',
path: '/'
});
};

52
src/utils/logger.ts Normal file
View File

@ -0,0 +1,52 @@
import winston from '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.addColors(colors);
const format = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}`,
),
);
const transports = [
new winston.transports.Console(),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: winston.format.json(),
}),
new winston.transports.File({ filename: 'logs/all.log', format: winston.format.json() }),
];
const logger = winston.createLogger({
level: level(),
levels,
format,
transports,
});
export default logger;

5
src/utils/prisma.ts Normal file
View File

@ -0,0 +1,5 @@
import { PrismaClient } from '../generated/prisma';
const prisma = new PrismaClient();
export default prisma;

125
src/utils/shell.ts Normal file
View File

@ -0,0 +1,125 @@
import { spawn } from 'child_process';
import config from '../config';
interface CommandOutput {
code: number | null;
stdout: string;
stderr: string;
}
export const executeCommand = (command: string, args: string[], input?: string): Promise<CommandOutput> => {
return new Promise((resolve, reject) => {
const child = spawn(command, args);
let stdout = '';
let stderr = '';
if (input && child.stdin) {
child.stdin.write(input);
child.stdin.end();
}
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('close', (code: number) => {
resolve({ code, stdout, stderr });
});
child.on('error', (err: Error) => {
reject(err);
});
});
};
export const escapeShell = (arg: string): string => {
return `'${arg.replace(/'/g, "'\\''")}'`;
};
const ALLOWED_SSH_COMMANDS = ['terraform', 'mkdir', 'cp', 'cd', 'rm', 'test', 'cat'];
const validateSshCommand = (cmd: string) => {
// 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(', ')}`);
}
}
};
export const executeSsh = async (remoteCmd: string): Promise<CommandOutput> => {
validateSshCommand(remoteCmd);
const args = [
'-i', config.terraform.sshKeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
`${config.terraform.sshUser}@${config.terraform.sshHost}`,
remoteCmd
];
return executeCommand('ssh', args);
};
export const uploadFileSsh = async (remotePath: string, content: string): Promise<CommandOutput> => {
const escapedPath = escapeShell(remotePath);
const args = [
'-i', config.terraform.sshKeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
`${config.terraform.sshUser}@${config.terraform.sshHost}`,
`cat > ${escapedPath}`
];
return executeCommand('ssh', args, content);
};
export const rsync = async (src: string, dest: string): Promise<CommandOutput> => {
const sshCmd = `ssh -i ${config.terraform.sshKeyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
const remoteDest = `${config.terraform.sshUser}@${config.terraform.sshHost}:${dest}`;
const args = [
'-az',
'--delete',
'-e', sshCmd,
src,
remoteDest
];
return executeCommand('rsync', args);
};
export const copyDirectorySsh = async (src: string, dest: string): Promise<CommandOutput> => {
// 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.terraform.sshUser}@${config.terraform.sshHost}:${dest}`;
const args = [
'-i', config.terraform.sshKeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-r',
src,
remoteDest
];
return executeCommand('scp', args);
};

9
terraform.tfstate Normal file
View File

@ -0,0 +1,9 @@
{
"version": 4,
"terraform_version": "1.13.3",
"serial": 1,
"lineage": "a94d6dc1-4ba1-3414-c983-f49070903276",
"outputs": {},
"resources": [],
"check_results": null
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}