initial commit

This commit is contained in:
corenthin 2025-10-06 13:35:34 +02:00
parent 4cc1853061
commit a1f1376aaa
27 changed files with 3740 additions and 18 deletions

31
.gitignore vendored
View File

@ -1,22 +1,17 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
# Rust target
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Env files
.env
.env.*
!.env.example
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Terraform drop/output directories
terraform_drop/
# OS/editor junk
.DS_Store
*.swp
.idea/
.vscode/

3099
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[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"] }

22
config/default.toml Normal file
View File

@ -0,0 +1,22 @@
[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"

1
crates/api/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

22
crates/api/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[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

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

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

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

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

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

View File

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

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

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

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

View File

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

@ -0,0 +1,32 @@
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(())
}

11
crates/api/src/main.rs Normal file
View File

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

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

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

View File

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

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

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

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

View File

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

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

@ -0,0 +1,52 @@
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('"', "\\\"")
}