Compare commits

...

16 Commits
master ... test

45 changed files with 3802 additions and 269 deletions

44
.env.example Normal file
View File

@ -0,0 +1,44 @@
# Service
APP__SERVICE__HOST=127.0.0.1
APP__SERVICE__PORT=3000
# CORS
# To expose array via env, use indexed variables (optional):
# APP__SERVICE__CORS_ALLOWED_ORIGINS__0=http://localhost:3000
# APP__SERVICE__CORS_ALLOWED_ORIGINS__1=http://localhost:5173
APP__SERVICE__CORS_ALLOW_CREDENTIALS=true
# Database (MySQL)
# Format: mysql://user:password@host:port/database
APP__DATABASE__URL=mysql://user:password@localhost:3306/apiprojetsolyti
APP__DATABASE__MAX_CONNECTIONS=10
# Guacamole API
APP__GUACAMOLE__API_ENDPOINT=https://guacamole.local/api
APP__GUACAMOLE__USERNAME=
APP__GUACAMOLE__PASSWORD=
# pfSense API
APP__PFSENSE__API_ENDPOINT=https://pfsense.local/api/v1
APP__PFSENSE__API_KEY=
APP__PFSENSE__API_SECRET=
# Terraform drop directory (where tfvars files are written)
APP__TERRAFORM__DROP_DIR=terraform_drop
# Auth (JWT)
# Use a strong random secret in production
APP__AUTH__JWT_SECRET=dev_local_secret_change_me
APP__AUTH__JWT_TTL_SECONDS=3600
APP__AUTH__PASSWORD_MIN_LENGTH=12
APP__AUTH__PASSWORD_REQUIRE_UPPERCASE=true
APP__AUTH__PASSWORD_REQUIRE_DIGIT=true
APP__AUTH__PASSWORD_REQUIRE_SPECIAL=true
APP__AUTH__MAX_LOGIN_ATTEMPTS=5
APP__AUTH__LOCKOUT_SECONDS=900
APP__AUTH__COOKIE_NAME=session
APP__AUTH__COOKIE_SECURE=false
APP__AUTH__COOKIE_HTTP_ONLY=true
APP__AUTH__COOKIE_SAME_SITE=Lax
# Optionnel: domain/path si nécessaire derrière un reverse proxy
APP__AUTH__COOKIE_DOMAIN=
APP__AUTH__COOKIE_PATH=/

1
.gitignore vendored
View File

@ -15,3 +15,4 @@ terraform_drop/
*.swp
.idea/
.vscode/
docker/

323
Cargo.lock generated
View File

@ -65,28 +65,70 @@ name = "api"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"axum-extra",
"chrono",
"config",
"dotenvy",
"rand",
"guacamole",
"jsonwebtoken",
"rand_core",
"reqwest",
"schemars",
"serde",
"serde_json",
"serde_urlencoded",
"sqlx",
"thiserror",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrrg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17afc961e8e12d18ed0afb51781b74853c6512da83144405ac34136a8d07e719"
dependencies = [
"arrrg_derive",
"getopts",
]
[[package]]
name = "arrrg_derive"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f21fd96446f29bb19923ace3aededea25f26beb4c69d59a24caddc6b8f536a7f"
dependencies = [
"derive_util",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -174,6 +216,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"fastrand",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"mime",
"multer",
"pin-project-lite",
"serde",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@ -195,6 +261,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.0"
@ -216,6 +288,15 @@ dependencies = [
"serde",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -266,7 +347,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@ -324,6 +408,17 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -406,6 +501,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_util"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6996c0c62d1c0c49b0b9f12f0a958d49b11b2eddb70de398a7c002528246f82"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -621,6 +736,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@ -628,8 +752,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -650,6 +776,17 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "guacamole"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7acc004c32e8e79b77bfcf5db6c5d94270715e3125ae4295ca6b853d01639d"
dependencies = [
"arrrg",
"arrrg_derive",
"getopts",
]
[[package]]
name = "h2"
version = "0.3.27"
@ -1064,6 +1201,21 @@ dependencies = [
"serde",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -1197,6 +1349,23 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http 1.3.1",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -1216,6 +1385,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
@ -1233,6 +1412,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
@ -1311,6 +1496,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -1323,6 +1519,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@ -1429,6 +1635,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1524,7 +1736,7 @@ version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [
"base64",
"base64 0.21.7",
"bytes",
"encoding_rs",
"futures-core",
@ -1579,7 +1791,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
dependencies = [
"base64",
"base64 0.21.7",
"bitflags 2.9.4",
"serde",
"serde_derive",
@ -1652,7 +1864,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"base64",
"base64 0.21.7",
]
[[package]]
@ -1859,6 +2071,18 @@ dependencies = [
"rand_core",
]
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.17",
"time",
]
[[package]]
name = "slab"
version = "0.4.11"
@ -1966,12 +2190,11 @@ dependencies = [
"sha2",
"smallvec",
"sqlformat",
"thiserror",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tracing",
"url",
"uuid",
]
[[package]]
@ -2020,7 +2243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
dependencies = [
"atoi",
"base64",
"base64 0.21.7",
"bitflags 2.9.4",
"byteorder",
"bytes",
@ -2051,9 +2274,8 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 1.0.69",
"tracing",
"uuid",
"whoami",
]
@ -2064,7 +2286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
dependencies = [
"atoi",
"base64",
"base64 0.21.7",
"bitflags 2.9.4",
"byteorder",
"chrono",
@ -2091,9 +2313,8 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 1.0.69",
"tracing",
"uuid",
"whoami",
]
@ -2119,7 +2340,6 @@ dependencies = [
"tracing",
"url",
"urlencoding",
"uuid",
]
[[package]]
@ -2230,7 +2450,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl 2.0.17",
]
[[package]]
@ -2244,6 +2473,17 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "thiserror-impl"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@ -2253,6 +2493,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@ -2408,6 +2679,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.9.4",
"bytes",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@ -2533,6 +2820,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode_categories"
version = "0.1.1"

View File

@ -12,16 +12,22 @@ license = "MIT"
[workspace.dependencies]
anyhow = "1"
axum = "0.7"
axum-extra = { version = "0.9", features = ["cookie"] }
config = "0.14"
dotenvy = "0.15"
rand = { version = "0.8", features = ["std"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
argon2 = "0.5"
jsonwebtoken = "9"
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"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "mysql", "chrono"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "io-util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
uuid = { version = "1", features = ["serde", "v4"] }
tower-http = { version = "0.5", features = ["cors"] }
rand_core = "0.6"
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
serde_urlencoded = "0.7"
urlencoding = "2"

View File

@ -1,6 +1,9 @@
[service]
host = "127.0.0.1"
port = 3000
# In dev, specify allowed origins for frontend
cors_allowed_origins = ["http://localhost:3000", "http://localhost:5173", "http://192.168.1.52:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
cors_allow_credentials = true
[database]
url = "mysql://user:password@localhost:3306/apiprojetsolyti"
@ -8,9 +11,9 @@ max_connections = 10
[guacamole]
api_endpoint = "https://guacamole.local/api"
username = ""
password = ""
api_endpoint = "https://guacamole.firewax.fr/guacamole/api"
username = "guacadmin"
password = "guacadmin"
[pfsense]
api_endpoint = "https://pfsense.local/api/v1"
@ -20,3 +23,38 @@ api_secret = ""
[terraform]
# Local drop directory where API writes generated tfvars files
drop_dir = "terraform_drop"
template_path = "/home/corenthin/terraform"
deploy_base_path = "/home/corenthin/"
ssh_host = "127.0.0.1"
ssh_user = "corenthin"
ssh_key_path = "/home/corenthin/.ssh/testapi"
proxmox_url = "https://proxmox.firewax.fr/api2/json"
proxmox_token_id = "terraform-prov@pve!mytoken"
proxmox_token_secret = "ca5dc532-b1b2-4656-a9ce-32375e2e0c28"
proxmox_insecure_tls = true
target_node = "pve"
model_windows = "e1000"
model_linux = "virtio"
[auth]
# Change in .env for production
jwt_secret = "change_me_in_env"
jwt_ttl_seconds = 3600
password_min_length = 12
password_require_uppercase = true
password_require_digit = true
password_require_special = true
max_login_attempts = 5
lockout_seconds = 900
cookie_name = "session"
cookie_secure = true
cookie_http_only = true
cookie_same_site = "None" # Lax|Strict|None
cookie_domain = ""
cookie_path = "/"
[admin]
email = "corenthin.lebreton@proton.me"
display_name = "Corenthin"
# password intentionally unset; provide via env variable APP_ADMIN__PASSWORD
allowed_email_domain = "solyti.fr"

View File

@ -7,16 +7,26 @@ description = "Automation API for Guacamole and pfSense orchestration with Terra
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
axum-extra = { workspace = true }
config = { workspace = true }
dotenvy = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
argon2 = { workspace = true }
jsonwebtoken = { 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 }
tower-http = { workspace = true }
rand_core = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
serde_urlencoded = { workspace = true }
urlencoding = { workspace = true }
guacamole = "0.14.0"
[dev-dependencies]
tower = "0.5"

View File

@ -9,12 +9,17 @@ pub struct AppConfig {
pub guacamole: GuacamoleConfig,
pub pfsense: PfSenseConfig,
pub terraform: TerraformConfig,
pub auth: AuthConfig,
pub admin: AdminConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceConfig {
pub host: String,
pub port: u16,
// CORS
pub cors_allowed_origins: Option<Vec<String>>, // if None: allow all origins (dev)
pub cors_allow_credentials: bool,
}
#[derive(Debug, Deserialize, Clone)]
@ -34,14 +39,57 @@ pub struct PfSenseConfig {
#[derive(Debug, Deserialize, Clone)]
pub struct TerraformConfig {
pub drop_dir: String,
pub template_path: String,
pub deploy_base_path: String,
pub ssh_host: String,
pub ssh_user: String,
pub ssh_key_path: String,
pub proxmox_url: String,
pub proxmox_token_id: String,
pub proxmox_token_secret: String,
pub proxmox_insecure_tls: bool,
pub target_node: String,
pub model_windows: String,
pub model_linux: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
pub jwt_secret: String,
pub jwt_ttl_seconds: u64,
pub password_min_length: u16,
pub password_require_uppercase: bool,
pub password_require_digit: bool,
pub password_require_special: bool,
pub max_login_attempts: u32,
pub lockout_seconds: u64,
pub cookie_name: String,
pub cookie_secure: bool,
pub cookie_http_only: bool,
pub cookie_same_site: String,
pub cookie_domain: Option<String>,
pub cookie_path: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AdminConfig {
pub email: String,
pub display_name: String,
pub password: Option<String>,
pub allowed_email_domain: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
service: ServiceConfig {
host: "127.0.0.1".to_string(),
host: "192.168.1.52".to_string(),
port: 3000,
cors_allowed_origins: Some(vec![
"http://localhost:3000".to_string(),
"http://localhost:5173".to_string(),
]),
cors_allow_credentials: true,
},
database: database::DatabaseConfig::default(),
guacamole: GuacamoleConfig {
@ -56,6 +104,40 @@ impl Default for AppConfig {
},
terraform: TerraformConfig {
drop_dir: "terraform_drop".to_string(),
template_path: "/home/terraform".to_string(),
deploy_base_path: "/home".to_string(),
ssh_host: "192.168.1.52".to_string(),
ssh_user: "corenthin".to_string(),
ssh_key_path: "/home/corenthin/.ssh/testapi".to_string(),
proxmox_url: "https://proxmox.firewax.fr/api2/json".to_string(),
proxmox_token_id: "xxx".to_string(),
proxmox_token_secret: "".to_string(),
proxmox_insecure_tls: true,
target_node: "node1".to_string(),
model_windows: "e1000".to_string(),
model_linux: "virtio".to_string(),
},
auth: AuthConfig {
jwt_secret: "change_me_in_env".to_string(),
jwt_ttl_seconds: 3600,
password_min_length: 12,
password_require_uppercase: true,
password_require_digit: true,
password_require_special: true,
max_login_attempts: 5,
lockout_seconds: 900,
cookie_name: "session".to_string(),
cookie_secure: false,
cookie_http_only: true,
cookie_same_site: "Lax".to_string(),
cookie_domain: None,
cookie_path: Some("/".to_string()),
},
admin: AdminConfig {
email: "corenthin.lebreton@proton.me".to_string(),
display_name: "Corenthin".to_string(),
password: None,
allowed_email_domain: "solyti.fr".to_string(),
},
}
}

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

5
crates/api/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod bootstrap;
pub mod config;
pub mod infrastructure;
pub mod routes;
pub mod services;

View File

@ -1,6 +1,5 @@
mod bootstrap;
pub mod config;
pub mod domain;
pub mod infrastructure;
pub mod routes;
pub mod services;

View File

@ -0,0 +1,472 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use axum_extra::extract::CookieJar;
use std::future::Future;
use tracing::info;
use uuid::Uuid;
use crate::{
routes::{
auth::{
db::{delete_user, find_user_by_id, insert_user, list_users, update_user},
helpers::{error_response, session_token, validate_password},
models::{DbUser, MeUser},
},
AppState,
},
services::auth::{hash_password, TokenClaims},
};
use super::models::{
AdminCreateUserRequest, AdminDashboardResponse, AdminUpdateUserRequest, AdminUserSummary,
};
#[allow(clippy::result_large_err)]
fn validate_admin_email(state: &AppState, email: &str) -> Result<(), Response> {
let domain = state
.config
.admin
.allowed_email_domain
.trim()
.to_ascii_lowercase();
if domain.is_empty() {
return Ok(());
}
let email_lower = email.to_ascii_lowercase();
let expected_suffix = format!("@{domain}");
if !email_lower.ends_with(&expected_suffix) {
return Err(error_response(
StatusCode::BAD_REQUEST,
"email domain not allowed, try again".to_string(),
));
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn normalize_email(state: &AppState, raw: &str) -> Result<String, Response> {
let email = raw.trim();
if email.is_empty() {
return Err(error_response(StatusCode::BAD_REQUEST, "invalid email"));
}
validate_admin_email(state, email).map(|_| email.to_string())
}
fn trim_display(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn clean_display_name(input: &Option<String>) -> Option<String> {
input.as_ref().and_then(|value| trim_display(value))
}
fn resolve_display_name(update: &Option<Option<String>>, existing: &DbUser) -> Option<String> {
match update {
Some(Some(value)) => trim_display(value),
Some(None) => None,
None => existing.display_name_option(),
}
}
#[allow(clippy::result_large_err)]
fn hash_required_password(password: &str, state: &AppState) -> Result<String, Response> {
if let Err(msg) = validate_password(password, &state.config.auth) {
return Err(error_response(StatusCode::BAD_REQUEST, msg));
}
hash_password(password).map_err(|error| {
tracing::error!(%error, "hashing failed");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
})
}
#[allow(clippy::result_large_err)]
fn hash_optional_password(
password: Option<&String>,
state: &AppState,
) -> Result<Option<String>, Response> {
match password {
Some(value) => hash_required_password(value, state).map(Some),
None => Ok(None),
}
}
async fn with_admin<F, Fut>(
state: &AppState,
jar: &CookieJar,
headers: &HeaderMap,
handler: F,
) -> Response
where
F: FnOnce(DbUser) -> Fut,
Fut: Future<Output = Response>,
{
match authenticate_admin(state, jar, headers).await {
Ok(admin) => handler(admin).await,
Err(resp) => resp,
}
}
#[allow(clippy::result_large_err)]
async fn load_user(state: &AppState, user_id: &Uuid) -> Result<DbUser, Response> {
match find_user_by_id(&state.db, user_id).await {
Ok(Some(user)) => Ok(user),
Ok(None) => Err(error_response(StatusCode::NOT_FOUND, "user not found")),
Err(error) => {
tracing::error!(%error, "find_user_by_id failed");
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
))
}
}
}
pub async fn dashboard(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
) -> Response {
let state_clone = state.clone();
with_admin(&state, &jar, &headers, move |current_user| {
let state = state_clone.clone();
async move {
match list_users(&state.db).await {
Ok(users) => {
let summaries = users.iter().map(AdminUserSummary::from).collect();
Json(AdminDashboardResponse {
current_user: MeUser::from(&current_user),
users: summaries,
})
.into_response()
}
Err(error) => {
tracing::error!(%error, "list_users failed");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
})
.await
}
pub async fn list(State(state): State<AppState>, jar: CookieJar, headers: HeaderMap) -> Response {
let state_clone = state.clone();
with_admin(&state, &jar, &headers, move |_| {
let state = state_clone.clone();
async move {
match list_users(&state.db).await {
Ok(users) => {
let summaries: Vec<_> = users.iter().map(AdminUserSummary::from).collect();
Json(summaries).into_response()
}
Err(error) => {
tracing::error!(%error, "list_users failed");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
})
.await
}
pub async fn create_user(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Json(body): Json<AdminCreateUserRequest>,
) -> Response {
let AdminCreateUserRequest {
email: raw_email,
password: raw_password,
display_name,
is_admin,
} = body;
let state_clone = state.clone();
with_admin(&state, &jar, &headers, move |admin_user| {
let state = state_clone.clone();
async move {
let email = match normalize_email(&state, &raw_email) {
Ok(email) => email,
Err(resp) => return resp,
};
let password_hash = match hash_required_password(&raw_password, &state) {
Ok(hash) => hash,
Err(resp) => return resp,
};
let display_name = clean_display_name(&display_name);
match insert_user(
&state.db,
&email,
&password_hash,
display_name.as_deref(),
is_admin,
)
.await
{
Ok(id) => match find_user_by_id(&state.db, &id).await {
Ok(Some(user)) => {
info!(
admin_user_id = %admin_user.id,
admin_email = %admin_user.email,
created_user_id = %user.id,
created_email = %user.email,
"admin created user",
);
(StatusCode::CREATED, Json(AdminUserSummary::from(&user))).into_response()
}
Ok(None) => {
error_response(StatusCode::INTERNAL_SERVER_ERROR, "user creation failed")
}
Err(error) => {
tracing::error!(%error, "find_user_by_id failed after insert");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
},
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
error_response(StatusCode::CONFLICT, "email already registered")
}
Err(error) => {
tracing::error!(%error, "insert_user failed");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
})
.await
}
pub async fn update_user_handler(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
Json(body): Json<AdminUpdateUserRequest>,
) -> Response {
let state_clone = state.clone();
with_admin(&state, &jar, &headers, move |admin_user| {
let state = state_clone.clone();
async move {
let existing = match load_user(&state, &user_id).await {
Ok(user) => user,
Err(resp) => return resp,
};
let default_admin_email = state.config.admin.email.clone();
let target_is_default_admin = existing.email.eq_ignore_ascii_case(&default_admin_email);
if target_is_default_admin && admin_user.id != existing.id {
return error_response(
StatusCode::FORBIDDEN,
"default administrator can only modify their own profile",
);
}
let mut email = existing.email.clone();
if let Some(raw) = body.email.as_ref() {
let normalized = match normalize_email(&state, raw) {
Ok(value) => value,
Err(resp) => return resp,
};
if target_is_default_admin && !normalized.eq_ignore_ascii_case(&default_admin_email)
{
return error_response(
StatusCode::BAD_REQUEST,
"default administrator email cannot change",
);
}
email = normalized;
}
let password_hash_override =
match hash_optional_password(body.password.as_ref(), &state) {
Ok(hash) => hash,
Err(resp) => return resp,
};
let display_name = resolve_display_name(&body.display_name, &existing);
let requested_is_admin = body.is_admin.unwrap_or(existing.is_admin);
if target_is_default_admin && requested_is_admin != existing.is_admin {
return error_response(
StatusCode::BAD_REQUEST,
"default administrator rights cannot change",
);
}
let is_admin = if target_is_default_admin {
existing.is_admin
} else {
requested_is_admin
};
match update_user(
&state.db,
&user_id,
&email,
password_hash_override.as_deref(),
display_name.as_deref(),
is_admin,
)
.await
{
Ok(_) => match find_user_by_id(&state.db, &user_id).await {
Ok(Some(user)) => {
info!(
admin_user_id = %admin_user.id,
admin_email = %admin_user.email,
updated_user_id = %user.id,
updated_email = %user.email,
"admin updated user",
);
Json(AdminUserSummary::from(&user)).into_response()
}
Ok(None) => {
error_response(StatusCode::INTERNAL_SERVER_ERROR, "user reload failed")
}
Err(error) => {
tracing::error!(%error, "find_user_by_id failed after update");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
},
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
error_response(StatusCode::CONFLICT, "email already registered")
}
Err(error) => {
tracing::error!(%error, "update_user failed");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
})
.await
}
pub async fn delete_user_handler(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(user_id): Path<Uuid>,
) -> Response {
let state_clone = state.clone();
with_admin(&state, &jar, &headers, move |current_user| {
let state = state_clone.clone();
async move {
if current_user.id == user_id {
return error_response(StatusCode::BAD_REQUEST, "cannot delete yourself");
}
let target_user = match load_user(&state, &user_id).await {
Ok(user) => user,
Err(resp) => return resp,
};
let default_admin_email = state.config.admin.email.clone();
if target_user.email.eq_ignore_ascii_case(&default_admin_email)
&& current_user.id != target_user.id
{
return error_response(
StatusCode::FORBIDDEN,
"default administrator cannot be deleted",
);
}
match delete_user(&state.db, &user_id).await {
Ok(_) => {
info!(
admin_user_id = %current_user.id,
admin_email = %current_user.email,
deleted_user_id = %user_id,
deleted_email = %target_user.email,
"admin deleted user",
);
StatusCode::NO_CONTENT.into_response()
}
Err(error) => {
tracing::error!(%error, "delete_user failed");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
})
.await
}
#[allow(clippy::result_large_err)]
async fn authenticate_admin(
state: &AppState,
jar: &CookieJar,
headers: &HeaderMap,
) -> Result<DbUser, Response> {
let Some(token) = session_token(&state.config.auth, jar, headers) else {
return Err(error_response(StatusCode::UNAUTHORIZED, "unauthenticated"));
};
let claims = match state.jwt.validate(&token) {
Ok(claims) => claims,
Err(error) => {
tracing::warn!(%error, "invalid session token");
return Err(error_response(StatusCode::UNAUTHORIZED, "invalid session"));
}
};
match fetch_admin(state, claims).await {
Ok(user) => Ok(user),
Err(AdminAuthError::Unauthorized) => Err(error_response(
StatusCode::FORBIDDEN,
"admin privileges required",
)),
Err(AdminAuthError::Unauthenticated) => {
Err(error_response(StatusCode::UNAUTHORIZED, "unauthenticated"))
}
Err(AdminAuthError::Db(error)) => {
tracing::error!(%error, "admin lookup failed");
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
))
}
}
}
enum AdminAuthError {
Unauthenticated,
Unauthorized,
Db(sqlx::Error),
}
async fn fetch_admin(state: &AppState, claims: TokenClaims) -> Result<DbUser, AdminAuthError> {
let user = match find_user_by_id(&state.db, &claims.user_id).await {
Ok(Some(user)) if !user.id.is_nil() => user,
Ok(_) => return Err(AdminAuthError::Unauthenticated),
Err(error) => return Err(AdminAuthError::Db(error)),
};
if user.email != claims.email {
return Err(AdminAuthError::Unauthenticated);
}
if !user.is_admin {
return Err(AdminAuthError::Unauthorized);
}
Ok(user)
}

View File

@ -0,0 +1,4 @@
mod handlers;
pub mod models;
pub use handlers::{create_user, dashboard, delete_user_handler, list, update_user_handler};

View File

@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::routes::auth::models::{DbUser, MeUser};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminDashboardResponse {
pub current_user: MeUser,
pub users: Vec<AdminUserSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUserSummary {
pub id: Uuid,
pub email: String,
pub display_name: Option<String>,
pub is_admin: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminCreateUserRequest {
pub email: String,
pub password: String,
pub display_name: Option<String>,
#[serde(default)]
pub is_admin: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpdateUserRequest {
pub email: Option<String>,
pub password: Option<String>,
#[serde(default)]
pub display_name: Option<Option<String>>,
pub is_admin: Option<bool>,
}
impl From<&DbUser> for AdminUserSummary {
fn from(value: &DbUser) -> Self {
Self {
id: value.id,
email: value.email.clone(),
display_name: value.display_name_option(),
is_admin: value.is_admin,
}
}
}

View File

@ -0,0 +1,144 @@
use anyhow::{anyhow, Result};
use axum::{
routing::{delete, get, post, put},
Router,
};
use crate::{
config::AppConfig,
infrastructure::{self, DatabasePool},
services::{
auth::{hash_password, JwtService},
bruteforce::AuthGuard,
},
};
use super::{admin, auth, cors, deployments, health, terraform};
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
pub db: DatabasePool,
pub auth_guard: AuthGuard,
pub jwt: JwtService,
}
impl AppState {
fn new(config: AppConfig, db: DatabasePool, auth_guard: AuthGuard, jwt: JwtService) -> Self {
Self {
config,
db,
auth_guard,
jwt,
}
}
}
pub async fn build_router(cfg: AppConfig) -> Result<Router> {
let db = infrastructure::database::create_pool(&cfg.database).await?;
let auth_guard = AuthGuard::new();
let jwt = JwtService::new(&cfg.auth.jwt_secret, cfg.auth.jwt_ttl_seconds);
ensure_default_admin(&db, &cfg).await?;
let app_state = AppState::new(cfg.clone(), db, auth_guard, jwt);
let cors_layer = cors::build_cors_layer(&cfg.service)?;
let router = Router::new()
.route("/healthz", get(health::healthz))
.route("/deployments", post(deployments::create_deployment))
.route("/deployments/schema", get(deployments::schema))
.route("/auth/login", post(auth::login))
.route("/auth/me", get(auth::me))
.route("/auth/logout", post(auth::logout))
.route("/admin", get(admin::dashboard))
.route("/admin/users", get(admin::list).post(admin::create_user))
.route(
"/admin/users/:id",
put(admin::update_user_handler).delete(admin::delete_user_handler),
)
.route("/terraform/apply", post(terraform::apply))
.route("/terraform/templates", get(terraform::templates))
.route("/terraform/formations", get(terraform::list))
.route(
"/terraform/formations/:formationId",
delete(terraform::destroy),
)
.with_state(app_state)
.layer(cors_layer);
Ok(router)
}
async fn ensure_default_admin(db: &DatabasePool, cfg: &AppConfig) -> Result<()> {
use crate::routes::auth::db::{find_user_by_email, insert_user, update_user};
let admin_email = cfg.admin.email.trim();
if admin_email.is_empty() {
return Err(anyhow!("default admin email is not configured"));
}
let display_name = cfg.admin.display_name.trim();
let display_name_owned = if display_name.is_empty() {
None
} else {
Some(display_name.to_string())
};
match find_user_by_email(db, admin_email).await? {
Some(user) => {
let target_display = display_name_owned.as_deref();
let mut needs_update =
!user.is_admin || user.display_name_option().as_deref() != target_display;
let password_override = cfg
.admin
.password
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(hash_password)
.transpose()?;
if password_override.is_some() {
needs_update = true;
}
if needs_update {
update_user(
db,
&user.id,
admin_email,
password_override.as_deref(),
target_display,
true,
)
.await?;
}
}
None => {
let password = cfg
.admin
.password
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
.ok_or_else(|| {
anyhow!(
"default admin password not configured; set APP_ADMIN__PASSWORD environment variable"
)
})?;
let password_hash = hash_password(password)?;
insert_user(
db,
admin_email,
&password_hash,
display_name_owned.as_deref(),
true,
)
.await?;
tracing::info!(email = admin_email, "default admin user created");
}
}
Ok(())
}

View File

@ -0,0 +1,139 @@
use sqlx::mysql::MySqlRow;
use sqlx::Row;
use uuid::Uuid;
use super::models::DbUser;
use crate::infrastructure::DatabasePool;
pub async fn insert_user(
db: &DatabasePool,
email: &str,
password_hash: &str,
display_name: Option<&str>,
is_admin: bool,
) -> Result<Uuid, sqlx::Error> {
let id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO users (id, email, password_hash, display_name, is_admin, created_at)
VALUES (?, ?, ?, ?, ?, NOW())"#,
)
.bind(id.to_string())
.bind(email)
.bind(password_hash)
.bind(display_name)
.bind(is_admin)
.execute(db)
.await?;
Ok(id)
}
pub async fn find_user_by_email(
db: &DatabasePool,
email: &str,
) -> Result<Option<DbUser>, sqlx::Error> {
let query = sqlx::query(
r#"SELECT id, email, password_hash, display_name, is_admin
FROM users WHERE email = ? LIMIT 1"#,
)
.bind(email);
query
.fetch_optional(db)
.await
.map(|row| row.map(map_user_row))
}
pub async fn find_user_by_id(
db: &DatabasePool,
user_id: &Uuid,
) -> Result<Option<DbUser>, sqlx::Error> {
let query = sqlx::query(
r#"SELECT id, email, password_hash, display_name, is_admin
FROM users WHERE id = ? LIMIT 1"#,
)
.bind(user_id.to_string());
query
.fetch_optional(db)
.await
.map(|row| row.map(map_user_row))
}
pub async fn list_users(db: &DatabasePool) -> Result<Vec<DbUser>, sqlx::Error> {
let rows = sqlx::query(
r#"SELECT id, email, password_hash, display_name, is_admin
FROM users
ORDER BY created_at ASC"#,
)
.fetch_all(db)
.await?;
Ok(rows.into_iter().map(map_user_row).collect())
}
pub async fn update_user(
db: &DatabasePool,
user_id: &Uuid,
email: &str,
password_hash: Option<&str>,
display_name: Option<&str>,
is_admin: bool,
) -> Result<(), sqlx::Error> {
if let Some(password_hash) = password_hash {
sqlx::query(
r#"UPDATE users
SET email = ?, password_hash = ?, display_name = ?, is_admin = ?
WHERE id = ?"#,
)
.bind(email)
.bind(password_hash)
.bind(display_name)
.bind(is_admin)
.bind(user_id.to_string())
.execute(db)
.await?;
} else {
sqlx::query(
r#"UPDATE users
SET email = ?, display_name = ?, is_admin = ?
WHERE id = ?"#,
)
.bind(email)
.bind(display_name)
.bind(is_admin)
.bind(user_id.to_string())
.execute(db)
.await?;
}
Ok(())
}
pub async fn delete_user(db: &DatabasePool, user_id: &Uuid) -> Result<(), sqlx::Error> {
sqlx::query(r#"DELETE FROM users WHERE id = ?"#)
.bind(user_id.to_string())
.execute(db)
.await?;
Ok(())
}
fn map_user_row(row: MySqlRow) -> DbUser {
DbUser {
id: parse_uuid(row.get::<String, _>("id")),
email: row.get("email"),
password_hash: row.get("password_hash"),
display_name: row
.try_get::<Option<String>, _>("display_name")
.ok()
.flatten(),
is_admin: row
.try_get::<i8, _>("is_admin")
.map(|flag| flag != 0)
.unwrap_or(false),
}
}
fn parse_uuid(id: String) -> Uuid {
Uuid::parse_str(&id).unwrap_or_else(|_| Uuid::nil())
}

View File

@ -0,0 +1,138 @@
use axum::{
extract::State,
http::{header::SET_COOKIE, HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use axum_extra::extract::CookieJar;
use super::{
db::{find_user_by_email, find_user_by_id},
helpers::{
build_base_cookie, build_session_cookie, error_response, record_failure, session_token,
},
models::{LoginRequest, LoginResponse, MeResponse, MeUser},
};
use crate::{
routes::AppState,
services::auth::{verify_password, TokenClaims},
};
use tracing::{info, warn};
pub async fn login(
State(state): State<AppState>,
jar: CookieJar,
Json(body): Json<LoginRequest>,
) -> impl IntoResponse {
let email = body.email.trim();
if state.auth_guard.is_locked(email).await {
return error_response(
StatusCode::TOO_MANY_REQUESTS,
"too many attempts, temporarily locked",
);
}
let user = match find_user_by_email(&state.db, email).await {
Ok(Some(user)) => user,
Ok(None) => {
record_failure(&state, email).await;
warn!(email = email, "login attempt for unknown user");
return error_response(StatusCode::UNAUTHORIZED, "invalid credentials");
}
Err(error) => {
tracing::error!(%error, "find_user_by_email failed");
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
match verify_password(&body.password, &user.password_hash) {
Ok(true) => {}
Ok(false) => {
record_failure(&state, email).await;
warn!(email = email, "login failed due to invalid password");
return error_response(StatusCode::UNAUTHORIZED, "invalid credentials");
}
Err(error) => {
tracing::error!(%error, "password verification failed");
record_failure(&state, email).await;
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
}
state.auth_guard.record_success(email).await;
let token = match state.jwt.issue(&user.id, &user.email) {
Ok(token) => token,
Err(error) => {
tracing::error!(%error, "jwt issue failed");
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
let cookie = build_session_cookie(&state.config.auth, &token);
let response = LoginResponse {
token,
user: MeUser::from(&user),
};
let display_name = user.display_name();
info!(email = email, user_id = %user.id, display_name = %display_name, "login successful");
(jar.add(cookie), Json(response)).into_response()
}
pub async fn me(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
) -> impl IntoResponse {
let token = session_token(&state.config.auth, &jar, &headers);
let Some(token) = token else {
return error_response(StatusCode::UNAUTHORIZED, "unauthenticated");
};
let claims = match state.jwt.validate(&token) {
Ok(claims) => claims,
Err(error) => {
tracing::warn!(%error, "invalid session token");
return error_response(StatusCode::UNAUTHORIZED, "invalid session");
}
};
match fetch_me(&state, claims).await {
Ok(user) => Json(MeResponse { user }).into_response(),
Err(MeError::Unauthorized) => error_response(StatusCode::UNAUTHORIZED, "invalid session"),
Err(MeError::Db(error)) => {
tracing::error!(%error, "failed to load session user");
error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
pub async fn logout(State(state): State<AppState>, _jar: CookieJar) -> impl IntoResponse {
let mut cookie = build_base_cookie(&state.config.auth, "");
cookie.make_removal();
([(SET_COOKIE, cookie.to_string())], StatusCode::NO_CONTENT)
}
enum MeError {
Unauthorized,
Db(sqlx::Error),
}
async fn fetch_me(state: &AppState, claims: TokenClaims) -> Result<MeUser, MeError> {
let user = match find_user_by_id(&state.db, &claims.user_id).await {
Ok(Some(user)) if !user.id.is_nil() => user,
Ok(_) => return Err(MeError::Unauthorized),
Err(error) => return Err(MeError::Db(error)),
};
if user.email != claims.email {
return Err(MeError::Unauthorized);
}
Ok(MeUser::from(&user))
}

View File

@ -0,0 +1,86 @@
use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use axum_extra::{
extract::cookie::{Cookie, SameSite},
extract::CookieJar,
};
use crate::{config::AuthConfig, routes::AppState};
pub fn validate_password(password: &str, policy: &AuthConfig) -> Result<(), String> {
if password.len() < policy.password_min_length as usize {
return Err(format!(
"password must be at least {} characters",
policy.password_min_length
));
}
if policy.password_require_uppercase && !password.chars().any(|c| c.is_ascii_uppercase()) {
return Err("password must contain an uppercase letter".into());
}
if policy.password_require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
return Err("password must contain a digit".into());
}
if policy.password_require_special && !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
return Err("password must contain a special character".into());
}
Ok(())
}
pub fn build_session_cookie(cfg: &AuthConfig, token: &str) -> Cookie<'static> {
let mut cookie = build_base_cookie(cfg, token);
let same_site = match cfg.cookie_same_site.to_ascii_lowercase().as_str() {
"strict" => Some(SameSite::Strict),
"none" => Some(SameSite::None),
_ => Some(SameSite::Lax),
};
if let Some(ss) = same_site {
cookie.set_same_site(ss);
}
cookie.into_owned()
}
pub fn build_base_cookie(cfg: &AuthConfig, value: impl Into<String>) -> Cookie<'static> {
let mut cookie = Cookie::new(cfg.cookie_name.clone(), value.into());
cookie.set_http_only(cfg.cookie_http_only);
cookie.set_secure(cfg.cookie_secure);
cookie.set_path(
cfg.cookie_path
.as_deref()
.filter(|p| !p.is_empty())
.unwrap_or("/"),
);
if let Some(domain) = cfg.cookie_domain.as_deref().filter(|d| !d.is_empty()) {
cookie.set_domain(domain.to_string());
}
cookie.into_owned()
}
pub async fn record_failure(state: &AppState, email: &str) {
state
.auth_guard
.record_failure(
email,
state.config.auth.max_login_attempts,
state.config.auth.lockout_seconds,
)
.await;
}
pub fn error_response(status: StatusCode, msg: impl Into<String>) -> Response {
(status, Json(serde_json::json!({ "error": msg.into() }))).into_response()
}
pub fn session_token(cfg: &AuthConfig, jar: &CookieJar, headers: &HeaderMap) -> Option<String> {
jar.get(&cfg.cookie_name)
.map(|cookie| cookie.value().to_string())
.or_else(|| {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer "))
.map(|raw| raw.trim().to_string())
})
}

View File

@ -0,0 +1,7 @@
pub mod db;
mod handlers;
pub mod helpers;
pub mod models;
pub use handlers::{login, logout, me};
pub use models::{LoginRequest, LoginResponse, MeResponse};

View File

@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoginResponse {
pub token: String,
pub user: MeUser,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeResponse {
pub user: MeUser,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MeUser {
pub id: Uuid,
pub email: String,
pub name: String,
pub display_name: Option<String>,
pub is_admin: bool,
}
#[derive(Clone)]
pub struct DbUser {
pub id: Uuid,
pub email: String,
pub password_hash: String,
pub display_name: Option<String>,
pub is_admin: bool,
}
impl DbUser {
pub fn display_name(&self) -> String {
self.display_name
.as_deref()
.filter(|s| !s.is_empty())
.map_or_else(|| self.email.clone(), |name| name.to_string())
}
pub fn display_name_option(&self) -> Option<String> {
self.display_name
.as_ref()
.cloned()
.filter(|s| !s.is_empty())
}
}
impl From<&DbUser> for MeUser {
fn from(value: &DbUser) -> Self {
Self {
id: value.id,
email: value.email.clone(),
name: value.display_name(),
display_name: value.display_name_option(),
is_admin: value.is_admin,
}
}
}

View File

@ -0,0 +1,36 @@
use anyhow::Result;
use axum::http::{HeaderValue, Method};
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
use crate::config::ServiceConfig;
pub fn build_cors_layer(cfg: &ServiceConfig) -> Result<CorsLayer> {
let origins = match &cfg.cors_allowed_origins {
Some(list) => {
let values: Vec<HeaderValue> = list
.iter()
.filter_map(|origin| HeaderValue::from_str(origin).ok())
.collect();
AllowOrigin::list(values)
}
None => AllowOrigin::any(),
};
let methods = AllowMethods::list(vec![
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::HEAD,
Method::OPTIONS,
]);
Ok(CorsLayer::new()
.allow_origin(origins)
.allow_methods(methods)
.allow_headers(AllowHeaders::mirror_request())
.allow_credentials(cfg.cors_allow_credentials))
}

View File

@ -3,18 +3,9 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{config::AppConfig, services::tfvars::TfvarsGenerator};
use crate::{routes::AppState, services::tfvars::TfvarsGenerator};
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self { config }
}
}
// Uses shared AppState from routes::AppState
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
@ -55,8 +46,8 @@ pub async fn create_deployment(
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);
let filename = format!("{}.tfvars", payload.name.replace(' ', "_"));
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");
@ -102,10 +93,10 @@ fn validate_request(req: &DeploymentRequest) -> Result<(), String> {
}
let start = req.ip_start_host.unwrap_or(50) as u32;
if start < 2 || start > 254 {
if !(2..=254).contains(&start) {
return Err("ip_start_host must be in [2, 254]".into());
}
let last = start + req.user_count.saturating_sub(1) as u32;
let last = start + req.user_count.saturating_sub(1);
if last > 254 {
return Err("ip range exceeds 192.168.143.254".into());
}

View File

@ -1,22 +1,10 @@
use anyhow::Result;
use axum::{
routing::{get, post},
Router,
};
use crate::config::AppConfig;
mod app;
mod cors;
pub mod admin;
pub mod auth;
pub mod deployments;
pub mod health;
pub mod terraform;
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)
}
pub use app::{build_router, AppState};

View File

@ -0,0 +1,98 @@
use chrono::{DateTime, Utc};
use sqlx::{mysql::MySqlRow, Row};
use uuid::Uuid;
use super::models::{TerraformFormation, TerraformFormationResponse};
use crate::infrastructure::DatabasePool;
pub async fn insert_formation(
db: &DatabasePool,
formation_id: &str,
remote_path: &str,
guacamole_group_name: Option<&str>,
) -> Result<TerraformFormationResponse, sqlx::Error> {
let id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO terraform_formations (id, formation_id, remote_path, guacamole_group_name)
VALUES (?, ?, ?, ?)
"#,
)
.bind(id.to_string())
.bind(formation_id)
.bind(remote_path)
.bind(guacamole_group_name)
.execute(db)
.await?;
find_by_formation_id(db, formation_id)
.await?
.ok_or(sqlx::Error::RowNotFound)
.map(TerraformFormationResponse::from)
}
pub async fn find_by_formation_id(
db: &DatabasePool,
formation_id: &str,
) -> Result<Option<TerraformFormation>, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT id, formation_id, remote_path, guacamole_group_name, created_at
FROM terraform_formations
WHERE formation_id = ?
LIMIT 1
"#,
)
.bind(formation_id)
.fetch_optional(db)
.await?;
row.map(map_row).transpose()
}
pub async fn list_formations(db: &DatabasePool) -> Result<Vec<TerraformFormation>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT id, formation_id, remote_path, guacamole_group_name, created_at
FROM terraform_formations
ORDER BY created_at DESC
"#,
)
.fetch_all(db)
.await?;
rows.into_iter().map(map_row).collect()
}
pub async fn delete_formation(db: &DatabasePool, formation_id: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM terraform_formations
WHERE formation_id = ?
"#,
)
.bind(formation_id)
.execute(db)
.await?;
Ok(result.rows_affected() > 0)
}
fn map_row(row: MySqlRow) -> Result<TerraformFormation, sqlx::Error> {
let id_str: String = row.try_get("id")?;
let id = Uuid::parse_str(&id_str).map_err(|err| sqlx::Error::Decode(Box::new(err)))?;
let formation_id: String = row.try_get("formation_id")?;
let remote_path: String = row.try_get("remote_path")?;
let guacamole_group_name: Option<String> = row.try_get("guacamole_group_name")?;
let created_at: DateTime<Utc> = row.try_get("created_at")?;
Ok(TerraformFormation {
id,
formation_id,
remote_path,
guacamole_group_name,
created_at,
})
}

View File

@ -0,0 +1,847 @@
use std::{borrow::Cow, time::Duration};
use anyhow::{anyhow, Context, Result};
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::{info, warn};
use crate::{
config::GuacamoleConfig,
routes::terraform::models::{
GuacamoleUserRequest, TerraformGuacamoleRequest, TerraformVmRequest,
},
};
const DEFAULT_RDP_PORT: u16 = 3389;
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[serde(rename = "authToken")]
auth_token: String,
#[serde(rename = "dataSource")]
data_source: Option<String>,
#[serde(rename = "availableDataSources")]
available_data_sources: Option<Vec<String>>,
}
pub async fn provision(
cfg: &GuacamoleConfig,
formation_id: &str,
guac_req: &TerraformGuacamoleRequest,
vms: &[TerraformVmRequest],
) -> Result<()> {
if guac_req.users.is_empty() {
info!(
formation = formation_id,
"no guacamole users provided, skipping provisioning"
);
return Ok(());
}
let session = GuacSession::login(cfg).await?;
info!(group = ?guac_req.group_name, formation = formation_id, "guacamole login successful");
let group_identifier = guac_req
.group_name
.clone()
.unwrap_or_else(|| format!("formation-{formation_id}"));
// Use the identifier as display name too
let group_display_name = group_identifier.clone();
// If no group name provided, try to extract a cleaner name from the formation ID
// The formation ID is typically "name-uuid", so we try to strip the UUID part.
let group_identifier = if guac_req.group_name.is_none() {
extract_name_from_id(formation_id).unwrap_or(group_identifier)
} else {
group_identifier
};
// Pre-check: Verify if resources already exist
info!(group = %group_identifier, "checking for existing guacamole resources");
session.check_existence(&group_identifier, &guac_req.users, vms).await?;
info!(group = %group_identifier, "ensuring guacamole group");
session.ensure_user_group(&group_identifier, &group_display_name).await?;
// Create or update users first
for user in &guac_req.users {
info!(user = %user.username, "creating or updating guacamole user");
session.create_or_update_user(user).await?;
}
let mut connections = Vec::new();
for vm in vms {
if vm.ip.trim().is_empty() {
warn!(
formation = formation_id,
vm = vm.name,
"vm ip missing, skipping guacamole connection creation"
);
continue;
}
info!(vm = %vm.name, ip = %vm.ip, "creating guacamole connection");
let connection_id = session
.create_or_update_connection(&group_identifier, vm)
.await?;
connections.push((connection_id, vm.clone()));
}
session
.set_group_members(&group_identifier, &guac_req.users)
.await?;
for (connection_id, vm) in connections {
if let Some(user) = match_user_for_vm(&vm, &guac_req.users) {
info!(user = %user.username, connection = %connection_id, "granting user access to connection");
session
.grant_user_connection(&user.username, &connection_id)
.await?;
} else {
warn!(vm = %vm.name, connection = %connection_id, "no matching user found for vm, granting group access");
session
.grant_group_connection(&group_identifier, &connection_id)
.await?;
}
}
info!(formation = formation_id, group = %group_identifier, "guacamole provisioning completed");
Ok(())
}
pub async fn delete_provisioning(
cfg: &GuacamoleConfig,
formation_id: &str,
guacamole_group_name: Option<&str>,
) -> Result<()> {
let session = GuacSession::login(cfg).await?;
let group_identifier = guacamole_group_name
.map(String::from)
.unwrap_or_else(|| format!("formation-{formation_id}"));
info!(group = %group_identifier, "cleaning up guacamole resources");
// 1. Get group members (users and connections)
let (users, connections) = session.get_group_members(&group_identifier).await?;
// 2. Delete users
for username in users {
info!(user = %username, "deleting guacamole user");
if let Err(e) = session.delete_user(&username).await {
warn!(user = %username, error = %e, "failed to delete guacamole user");
}
}
// 3. Delete connections
for identifier in connections {
info!(connection = %identifier, "deleting guacamole connection");
if let Err(e) = session.delete_connection(&identifier).await {
warn!(connection = %identifier, error = %e, "failed to delete guacamole connection");
}
}
// 4. Delete group
info!(group = %group_identifier, "deleting guacamole group");
if let Err(e) = session.delete_group(&group_identifier).await {
warn!(group = %group_identifier, error = %e, "failed to delete guacamole group");
}
Ok(())
}
struct GuacSession {
client: Client,
base: String,
data_source: String,
token: String,
}
impl GuacSession {
async fn login(cfg: &GuacamoleConfig) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("failed to build guacamole client")?;
let base = cfg.api_endpoint.trim_end_matches('/').to_string();
let body = serde_urlencoded::to_string([
("username", cfg.username.as_str()),
("password", cfg.password.as_str()),
])?;
let response = client
.post(format!("{base}/tokens"))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.body(body)
.send()
.await
.context("failed to request guacamole token")?;
let status = response.status();
let body = response
.text()
.await
.context("failed to read guacamole token response body")?;
if status == StatusCode::UNAUTHORIZED {
return Err(anyhow!("guacamole credentials rejected"));
}
if !status.is_success() {
warn!(%status, body = %truncate_for_log(&body), "guacamole returned non-success status for token request");
return Err(anyhow!("guacamole token request failed: status {status}"));
}
let token_response: TokenResponse = serde_json::from_str(&body).map_err(|error| {
warn!(
%status,
body = %truncate_for_log(&body),
%error,
"guacamole returned unexpected token response"
);
anyhow!("failed to parse guacamole token response")
})?;
let data_source = token_response
.data_source
.or_else(|| {
token_response
.available_data_sources
.and_then(|mut v| v.pop())
})
.ok_or_else(|| anyhow!("guacamole did not return a data source"))?;
Ok(Self {
client,
base,
data_source,
token: token_response.auth_token,
})
}
async fn check_existence(
&self,
group: &str,
users: &[GuacamoleUserRequest],
vms: &[TerraformVmRequest],
) -> Result<()> {
// Check Group
let url = format!("{}/session/data/{}/userGroups/{}", self.base, self.data_source, encode(group));
let response = self.client.get(&url).query(&self.auth_query()).send().await?;
if response.status().is_success() {
return Err(anyhow!("Group \"{group}\" already exists."));
}
// Check Users
for user in users {
let url = format!("{}/session/data/{}/users/{}", self.base, self.data_source, encode(&user.username));
let response = self.client.get(&url).query(&self.auth_query()).send().await?;
if response.status().is_success() {
return Err(anyhow!("User \"{}\" already exists.", user.username));
}
}
// Check Connections (by name)
for vm in vms {
if self.find_connection_identifier(&vm.name).await?.is_some() {
return Err(anyhow!("Connection for VM \"{}\" already exists.", vm.name));
}
}
Ok(())
}
async fn ensure_user_group(&self, identifier: &str, display_name: &str) -> Result<()> {
let url = format!("{}/session/data/{}/userGroups", self.base, self.data_source);
let body = json!({
"identifier": identifier,
"name": display_name,
"attributes": {
"disabled": "",
"expired": "",
"valid-from": "",
"valid-until": "",
}
});
let response = self
.client
.post(url)
.query(&self.auth_query())
.json(&body)
.send()
.await
.context("failed to create user group")?;
match response.status() {
StatusCode::CREATED | StatusCode::OK => Ok(()),
StatusCode::CONFLICT => Ok(()),
status => {
let text = response.text().await.unwrap_or_default();
Err(anyhow!(
"failed to create user group (status {status}): {text}"
))
}
}
}
async fn create_or_update_user(&self, user: &GuacamoleUserRequest) -> Result<()> {
let url = format!("{}/session/data/{}/users", self.base, self.data_source);
let body = json!({
"username": user.username,
"password": user.password,
"attributes": self.default_user_attributes(user.display_name.clone()),
});
let response = self
.client
.post(&url)
.query(&self.auth_query())
.json(&body)
.send()
.await
.context("failed to create guacamole user")?;
match response.status() {
StatusCode::CREATED | StatusCode::OK => Ok(()),
StatusCode::CONFLICT => {
let url = format!(
"{}/session/data/{}/users/{}",
self.base,
self.data_source,
encode(&user.username)
);
let response = self
.client
.put(&url)
.query(&self.auth_query())
.json(&body)
.send()
.await
.context("failed to update guacamole user")?;
if response.status().is_success() {
Ok(())
} else {
let text = response.text().await.unwrap_or_default();
Err(anyhow!("failed to update guacamole user: {text}"))
}
}
status => {
let text = response.text().await.unwrap_or_default();
Err(anyhow!(
"failed to create guacamole user (status {status}): {text}"
))
}
}
}
async fn set_group_members(&self, group: &str, users: &[GuacamoleUserRequest]) -> Result<()> {
let url = format!(
"{}/session/data/{}/userGroups/{}/memberUsers",
self.base,
self.data_source,
encode(group)
);
let mut ops = Vec::new();
for user in users {
ops.push(json!({
"op": "add",
"path": "/",
"value": user.username,
}));
}
let response = self
.client
.patch(&url)
.query(&self.auth_query())
.json(&ops)
.send()
.await
.context("failed to update group members")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(anyhow!(
"failed to update group members for group {group} (status {status}): {text}"
));
}
Ok(())
}
async fn create_or_update_connection(
&self,
_group_identifier: &str,
vm: &TerraformVmRequest,
) -> Result<String> {
let url = format!(
"{}/session/data/{}/connections",
self.base, self.data_source
);
// CHANGED: Use only VM name
let name = vm.name.clone();
let port = vm.rdp_port.unwrap_or(DEFAULT_RDP_PORT).to_string();
let mut parameters = serde_json::Map::new();
let hostname = vm
.ip
.split('/')
.next()
.unwrap_or(vm.ip.as_str())
.to_string();
parameters.insert("hostname".into(), json!(hostname));
parameters.insert("port".into(), json!(port));
parameters.insert("security".into(), json!("any"));
parameters.insert("ignore-cert".into(), json!("true"));
let rdp_username = "dev".to_string();
parameters.insert("username".into(), json!(rdp_username));
let rdp_password = "Formation123!".to_string();
parameters.insert("password".into(), json!(rdp_password));
if let Some(domain) = vm.rdp_domain.as_ref() {
parameters.insert("domain".into(), json!(domain));
}
let body = json!({
"name": name,
"parentIdentifier": "ROOT",
"protocol": "rdp",
"parameters": Value::Object(parameters),
"attributes": self.default_connection_attributes(),
});
let response = self
.client
.post(&url)
.query(&self.auth_query())
.json(&body)
.send()
.await
.context("failed to create guacamole connection")?;
match response.status() {
StatusCode::CREATED | StatusCode::OK => {
let identifier = extract_identifier(response, "connections").await?;
Ok(identifier)
}
StatusCode::CONFLICT => {
self.find_connection_identifier(&name)
.await?
.ok_or_else(|| {
anyhow!(
"connection '{name}' already exists but identifier could not be resolved"
)
})
}
status => {
let text = response.text().await.unwrap_or_default();
Err(anyhow!(
"failed to create guacamole connection (status {status}): {text}"
))
}
}
}
async fn find_connection_identifier(&self, name: &str) -> Result<Option<String>> {
let url = format!(
"{}/session/data/{}/connections",
self.base, self.data_source
);
let response = self
.client
.get(url)
.query(&self.auth_query())
.send()
.await
.context("failed to list guacamole connections")?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(anyhow!("failed to list connections: {text}"));
}
let connections: Value = response
.json()
.await
.context("failed to parse connections response")?;
let identifier = connections
.as_object()
.and_then(|map| map.get("data"))
.and_then(|data| data.as_array())
.and_then(|arr| {
arr.iter().find_map(|conn| {
let conn_name = conn.get("name")?.as_str()?;
if conn_name == name {
conn.get("identifier")
.and_then(|v| v.as_str())
.map(str::to_string)
} else {
None
}
})
});
Ok(identifier)
}
async fn grant_group_connection(&self, group: &str, connection_id: &str) -> Result<()> {
let url = format!(
"{}/session/data/{}/userGroups/{}/permissions",
self.base,
self.data_source,
encode(group)
);
let ops = vec![json!({
"op": "add",
"path": format!("/connectionPermissions/{}", connection_id),
"value": "READ",
})];
let response = self
.client
.patch(url)
.query(&self.auth_query())
.json(&ops)
.send()
.await
.context("failed to grant group connection access")?;
if response.status().is_success() {
Ok(())
} else {
let text = response.text().await.unwrap_or_default();
Err(anyhow!("failed to grant group connection access: {text}"))
}
}
async fn grant_user_connection(&self, username: &str, connection_id: &str) -> Result<()> {
let url = format!(
"{}/session/data/{}/users/{}/permissions",
self.base,
self.data_source,
encode(username)
);
let ops = vec![json!({
"op": "add",
"path": format!("/connectionPermissions/{}", connection_id),
"value": "READ",
})];
let response = self
.client
.patch(url)
.query(&self.auth_query())
.json(&ops)
.send()
.await
.context("failed to grant user connection access")?;
if response.status().is_success() {
Ok(())
} else {
let text = response.text().await.unwrap_or_default();
Err(anyhow!("failed to grant user connection access: {text}"))
}
}
async fn get_group_members(&self, group: &str) -> Result<(Vec<String>, Vec<String>)> {
let url = format!(
"{}/session/data/{}/userGroups/{}/memberUsers",
self.base,
self.data_source,
encode(group)
);
info!(url = %url, "fetching group member users");
let response = self
.client
.get(&url)
.query(&self.auth_query())
.send()
.await
.context("failed to list group member users")?;
let mut users = Vec::new();
let status = response.status();
if status.is_success() {
if let Ok(members) = response.json::<Vec<String>>().await {
info!(members = ?members, "found group member users");
users = members;
} else {
warn!("failed to parse group member users response");
}
} else {
let text = response.text().await.unwrap_or_default();
warn!(status = %status, text = %text, "failed to list group member users");
}
let url = format!(
"{}/session/data/{}/userGroups/{}/permissions",
self.base,
self.data_source,
encode(group)
);
info!(url = %url, "fetching group permissions");
let response = self
.client
.get(&url)
.query(&self.auth_query())
.send()
.await
.context("failed to list group permissions")?;
let mut connection_ids = Vec::new();
let status = response.status();
if status.is_success() {
if let Ok(perms) = response.json::<Value>().await {
// info!(perms = ?perms, "found group permissions");
if let Some(conn_perms) = perms.get("connectionPermissions") {
if let Some(obj) = conn_perms.as_object() {
connection_ids.extend(obj.keys().cloned());
info!(connections = ?connection_ids, "found group connection permissions");
}
}
} else {
warn!("failed to parse group permissions response");
}
} else {
let text = response.text().await.unwrap_or_default();
warn!(status = %status, text = %text, "failed to list group permissions");
}
// Also fetch permissions for each user to find connections assigned directly to them
for user in &users {
let url = format!(
"{}/session/data/{}/users/{}/permissions",
self.base,
self.data_source,
encode(user)
);
// info!(url = %url, user = %user, "fetching user permissions");
let response = self
.client
.get(&url)
.query(&self.auth_query())
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {
if let Ok(perms) = resp.json::<Value>().await {
if let Some(conn_perms) = perms.get("connectionPermissions") {
if let Some(obj) = conn_perms.as_object() {
let user_conns: Vec<String> = obj.keys().cloned().collect();
if !user_conns.is_empty() {
info!(user = %user, connections = ?user_conns, "found user connection permissions");
connection_ids.extend(user_conns);
}
}
}
}
}
}
Err(e) => {
warn!(user = %user, error = %e, "failed to fetch user permissions");
}
}
}
// Deduplicate connection IDs
connection_ids.sort();
connection_ids.dedup();
Ok((users, connection_ids))
}
async fn delete_user(&self, username: &str) -> Result<()> {
let url = format!(
"{}/session/data/{}/users/{}",
self.base,
self.data_source,
encode(username)
);
self.client
.delete(&url)
.query(&self.auth_query())
.send()
.await
.context("failed to delete user")
.map(|_| ())
}
async fn delete_group(&self, group: &str) -> Result<()> {
let url = format!(
"{}/session/data/{}/userGroups/{}",
self.base,
self.data_source,
encode(group)
);
self.client
.delete(&url)
.query(&self.auth_query())
.send()
.await
.context("failed to delete group")
.map(|_| ())
}
async fn delete_connection(&self, identifier: &str) -> Result<()> {
let url = format!(
"{}/session/data/{}/connections/{}",
self.base,
self.data_source,
encode(identifier)
);
self.client
.delete(&url)
.query(&self.auth_query())
.send()
.await
.context("failed to delete connection")
.map(|_| ())
}
fn auth_query(&self) -> [(&str, &str); 2] {
[("token", &self.token), ("dataSource", &self.data_source)]
}
fn default_user_attributes(
&self,
display_name: Option<String>,
) -> serde_json::Map<String, Value> {
let mut attributes = serde_json::Map::new();
attributes.insert("disabled".into(), json!(""));
attributes.insert("expired".into(), json!(""));
attributes.insert("valid-from".into(), json!(""));
attributes.insert("valid-until".into(), json!(""));
attributes.insert(
"guac-full-name".into(),
json!(display_name.unwrap_or_default()),
);
attributes.insert("guac-organization".into(), json!(""));
attributes.insert("guac-organizational-role".into(), json!(""));
attributes
}
fn default_connection_attributes(&self) -> serde_json::Map<String, Value> {
let mut attributes = serde_json::Map::new();
attributes.insert("max-connections".into(), json!(""));
attributes.insert("max-connections-per-user".into(), json!(""));
attributes.insert("weight".into(), json!(""));
attributes.insert("failover-only".into(), json!(""));
attributes
}
}
/// Avoid dumping sensitive tokens in logs while keeping context for debugging.
fn truncate_for_log(body: &str) -> Cow<'_, str> {
const LIMIT: usize = 256;
if body.len() <= LIMIT {
Cow::Borrowed(body)
} else {
let mut truncated = body[..LIMIT].to_string();
truncated.push('…');
Cow::Owned(truncated)
}
}
async fn extract_identifier(response: reqwest::Response, entity: &str) -> Result<String> {
if let Some(location) = response.headers().get("Location") {
if let Ok(location_str) = location.to_str() {
if let Some(identifier) = location_str.rsplit('/').next() {
if !identifier.is_empty() {
return Ok(identifier.to_string());
}
}
}
}
// fallback: attempt to parse body
let text = response.text().await.unwrap_or_default();
if !text.is_empty() {
if let Ok(value) = serde_json::from_str::<Value>(&text) {
if let Some(identifier) = value
.get("identifier")
.and_then(|v| v.as_str())
.map(str::to_string)
{
return Ok(identifier);
}
}
}
Err(anyhow!(
"failed to determine {entity} identifier from guacamole response"
))
}
fn match_user_for_vm<'a>(
vm: &TerraformVmRequest,
users: &'a [GuacamoleUserRequest],
) -> Option<&'a GuacamoleUserRequest> {
let vm_key = extract_numeric_suffix(&vm.name);
users.iter().find(|user| {
let user_key = extract_numeric_suffix(&user.username);
match (vm_key, user_key) {
(Some(vm_val), Some(user_val)) => vm_val == user_val,
_ => false,
}
})
}
fn extract_numeric_suffix(value: &str) -> Option<u32> {
// CHANGED: Find the LAST sequence of digits
let mut last_digits = String::new();
let mut current_digits = String::new();
for c in value.chars() {
if c.is_ascii_digit() {
current_digits.push(c);
} else if !current_digits.is_empty() {
last_digits = current_digits;
current_digits = String::new();
}
}
if !current_digits.is_empty() {
last_digits = current_digits;
}
if last_digits.is_empty() {
None
} else {
last_digits.parse().ok()
}
}
fn encode(input: &str) -> Cow<'_, str> {
urlencoding::encode(input)
}
pub fn extract_name_from_id(formation_id: &str) -> Option<String> {
// Expected format: "name-uuid"
// UUID is 36 chars long.
if formation_id.len() > 37 {
let (name, _uuid) = formation_id.split_at(formation_id.len() - 37);
// Verify the separator is a hyphen
if _uuid.starts_with('-') {
return Some(name.to_string());
}
}
None
}

View File

@ -0,0 +1,595 @@
mod db;
mod guacamole;
mod models;
mod proxmox;
pub use models::{
TerraformApplyRequest, TerraformFormationResponse, TerraformVmOs, TerraformVmRequest,
};
use std::{borrow::Cow, path::Path as StdPath};
use axum::extract::rejection::JsonRejection;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use tokio::{io::AsyncWriteExt, process::Command};
use tracing::{error, info, warn};
use crate::{
config::TerraformConfig,
routes::{auth::helpers::error_response, AppState},
services::tfvars::TfvarsGenerator,
};
pub async fn apply(
State(state): State<AppState>,
payload: Result<Json<TerraformApplyRequest>, JsonRejection>,
) -> Response {
let Json(mut body) = match payload {
Ok(body) => body,
Err(rejection) => {
let detail: Cow<'_, str> = match &rejection {
JsonRejection::JsonDataError(err) => {
format!("data error: {}", err.body_text()).into()
}
JsonRejection::JsonSyntaxError(err) => {
format!("syntax error: {}", err.body_text()).into()
}
JsonRejection::MissingJsonContentType(err) => {
format!("missing content-type: {}", err.body_text()).into()
}
JsonRejection::BytesRejection(err) => Cow::Owned(err.to_string()),
other => Cow::Owned(other.to_string()),
};
warn!(detail = %detail, "invalid terraform apply payload");
return rejection.into_response();
}
};
// Clean formation_id to remove UUID if present
if let Some(clean_name) = guacamole::extract_name_from_id(&body.formation_id) {
body.formation_id = clean_name;
}
if body.vms.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "no VMs provided");
}
if !is_valid_formation_id(&body.formation_id) {
return error_response(StatusCode::BAD_REQUEST, "invalid formation id");
}
info!(formation = %body.formation_id, vm_count = body.vms.len(), "terraform apply payload received");
let terraform_cfg = state.config.terraform.clone();
match db::find_by_formation_id(&state.db, &body.formation_id).await {
Ok(Some(_)) => {
return error_response(StatusCode::CONFLICT, "formation already exists");
}
Ok(None) => {}
Err(error) => {
error!(%error, formation = %body.formation_id, "failed to query formation");
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to create formation",
);
}
}
let request_payload = body.clone();
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(error) = handle_apply_job(state_clone, terraform_cfg, request_payload).await {
error!(formation = %error.formation_id, message = %error.message, "terraform apply task failed");
}
});
let remote_dir_preview = format!(
"{}/{}",
state
.config
.terraform
.deploy_base_path
.trim_end_matches('/'),
body.formation_id
);
let response_body = json!({
"status": "queued",
"formationId": body.formation_id,
"remotePath": remote_dir_preview,
});
(StatusCode::ACCEPTED, Json(response_body)).into_response()
}
pub async fn templates(State(state): State<AppState>) -> Response {
match proxmox::fetch_templates(&state.config.terraform).await {
Ok(templates) => (StatusCode::OK, Json(json!({ "templates": templates }))).into_response(),
Err(error) => {
error!(%error, "failed to fetch templates from proxmox");
error_response(
StatusCode::BAD_GATEWAY,
"failed to fetch templates from proxmox",
)
}
}
}
struct ApplyError {
formation_id: String,
message: String,
}
async fn handle_apply_job(
state: AppState,
terraform_cfg: TerraformConfig,
req: TerraformApplyRequest,
) -> Result<(), ApplyError> {
let formation_id = req.formation_id.clone();
let remote_dir = match prepare_remote_workspace(&terraform_cfg, &formation_id).await {
Ok(dir) => dir,
Err(resp) => {
return Err(ApplyError {
formation_id,
message: format!("prepare workspace failed: {}", resp.status()),
});
}
};
let tfvars = TfvarsGenerator::generate_vm_definitions(&req, &terraform_cfg);
if let Err(resp) = upload_tfvars(&terraform_cfg, &remote_dir, &tfvars).await {
let _ = run_ssh_command(
&terraform_cfg,
&format!("rm -rf {}", shell_escape(&remote_dir)),
)
.await;
return Err(ApplyError {
formation_id,
message: format!("upload tfvars failed: {}", resp.status()),
});
}
if let Err(resp) = terraform_init(&terraform_cfg, &remote_dir, true).await {
let _ = run_ssh_command(
&terraform_cfg,
&format!("rm -rf {}", shell_escape(&remote_dir)),
)
.await;
return Err(ApplyError {
formation_id,
message: format!("terraform init failed: {}", resp.status()),
});
}
let apply_cmd = format!(
"cd {} && terraform apply -auto-approve -input=false",
shell_escape(&remote_dir)
);
if let Err(resp) = run_ssh_command(&terraform_cfg, &apply_cmd).await {
let _ = run_ssh_command(
&terraform_cfg,
&format!("rm -rf {}", shell_escape(&remote_dir)),
)
.await;
return Err(ApplyError {
formation_id,
message: format!("terraform apply launch failed: {}", resp.status()),
});
}
let guacamole_group_name = req.guacamole.as_ref().and_then(|g| g.group_name.as_deref());
match db::insert_formation(
&state.db,
&req.formation_id,
&remote_dir,
guacamole_group_name,
)
.await
{
Ok(record) => {
info!(formation = %req.formation_id, path = %remote_dir, "terraform formation stored");
info!(formation = %req.formation_id, id = %record.id, "formation recorded in database");
if let Some(guac_req) = req.guacamole.clone() {
let guac_cfg = state.config.guacamole.clone();
let formation = req.formation_id.clone();
let vms = req.vms.clone();
info!(formation = %formation, users = guac_req.users.len(), vms = vms.len(), "queueing guacamole provisioning task");
tokio::spawn(async move {
info!(formation = %formation, "starting guacamole provisioning");
match guacamole::provision(&guac_cfg, &formation, &guac_req, &vms).await {
Ok(()) => info!(formation = %formation, "guacamole provisioning succeeded"),
Err(error) => {
warn!(%error, formation = %formation, "guacamole provisioning failed")
}
}
});
} else {
info!(formation = %req.formation_id, "no guacamole payload provided, skipping guacamole provisioning");
}
}
Err(error) => {
error!(%error, formation = %req.formation_id, "failed to persist formation");
}
}
Ok(())
}
pub async fn list(State(state): State<AppState>) -> Response {
match db::list_formations(&state.db).await {
Ok(formations) => {
let payload: Vec<_> = formations
.into_iter()
.map(TerraformFormationResponse::from)
.collect();
(StatusCode::OK, Json(json!({ "formations": payload }))).into_response()
}
Err(error) => {
error!(%error, "failed to list terraform formations");
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to list formations",
)
}
}
}
pub async fn destroy(State(state): State<AppState>, Path(formation_id): Path<String>) -> Response {
let terraform_cfg = &state.config.terraform;
let record = match db::find_by_formation_id(&state.db, &formation_id).await {
Ok(Some(record)) => record,
Ok(None) => return error_response(StatusCode::NOT_FOUND, "formation not found"),
Err(error) => {
error!(%error, formation = %formation_id, "failed to lookup formation");
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to destroy formation",
);
}
};
let escaped_dir = shell_escape(&record.remote_path);
if let Err(resp) = terraform_init(terraform_cfg, &record.remote_path, false).await {
return resp;
}
let destroy_cmd = format!(
"cd {escaped_dir} && terraform destroy -auto-approve -input=false"
);
if let Err(resp) = run_ssh_command(terraform_cfg, &destroy_cmd).await {
return resp;
}
if let Err(resp) = run_ssh_command(terraform_cfg, &format!("rm -rf {escaped_dir}")).await {
return resp;
}
// Fetch formation first to get group name for cleanup
let formation_opt = db::find_by_formation_id(&state.db, &formation_id).await.ok().flatten();
match db::delete_formation(&state.db, &formation_id).await {
Ok(success) => {
if let Some(formation) = formation_opt {
// Attempt to clean up Guacamole resources
let guac_cfg = state.config.guacamole.clone();
let formation_id_clone = formation_id.clone();
let group_name = formation.guacamole_group_name.clone();
tokio::spawn(async move {
if let Err(e) = guacamole::delete_provisioning(
&guac_cfg,
&formation_id_clone,
group_name.as_deref(),
)
.await
{
warn!(formation = %formation_id_clone, error = %e, "failed to clean up guacamole resources");
}
});
}
if !success {
warn!(formation = %formation_id, "formation absent during deletion");
}
StatusCode::NO_CONTENT.into_response()
}
Err(error) => {
error!(%error, formation = %formation_id, "failed to delete formation record");
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to destroy formation",
)
}
}
}
async fn prepare_remote_workspace(
cfg: &TerraformConfig,
formation_id: &str,
) -> Result<String, Response> {
let template_path = StdPath::new(&cfg.template_path);
if !template_path.exists() {
error!(
path = %template_path.display(),
"terraform template directory missing"
);
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"terraform template directory missing",
));
}
let remote_dir = format!(
"{}/{}",
cfg.deploy_base_path.trim_end_matches('/'),
formation_id
);
match check_remote_exists(cfg, &remote_dir).await {
Ok(true) => {
return Err(error_response(
StatusCode::CONFLICT,
"formation already exists on terraform host",
));
}
Ok(false) => {}
Err(resp) => return Err(resp),
}
run_ssh_command(cfg, &format!("mkdir -p {}", shell_escape(&remote_dir))).await?;
rsync_template(cfg, &remote_dir).await?;
Ok(remote_dir)
}
async fn check_remote_exists(cfg: &TerraformConfig, path: &str) -> Result<bool, Response> {
let mut command = Command::new("ssh");
command
.arg("-i")
.arg(&cfg.ssh_key_path)
.arg("-o")
.arg("StrictHostKeyChecking=no")
.arg("-o")
.arg("UserKnownHostsFile=/dev/null")
.arg("-o")
.arg("LogLevel=ERROR")
.arg(format!("{}@{}", cfg.ssh_user, cfg.ssh_host))
.arg(format!("test -d {}", shell_escape(path)));
match command.status().await {
Ok(status) if status.success() => Ok(true),
Ok(status) if status.code() == Some(1) => Ok(false),
Ok(status) => {
warn!(
return_code = status.code(),
"ssh test command returned unexpected status"
);
Ok(false)
}
Err(error) => {
error!(%error, "failed to execute ssh test command");
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"unable to reach terraform host",
))
}
}
}
async fn rsync_template(cfg: &TerraformConfig, remote_dir: &str) -> Result<(), Response> {
let template_src = format!("{}/", cfg.template_path.trim_end_matches('/'));
let remote_target = format!(
"{}@{}:{}/",
cfg.ssh_user,
cfg.ssh_host,
remote_dir.trim_end_matches('/')
);
let mut command = Command::new("rsync");
command
.arg("-az")
.arg("--delete")
.arg("-e")
.arg(format!(
"ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
cfg.ssh_key_path
))
.arg(&template_src)
.arg(&remote_target);
match command.output().await {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
error!(
status = ?output.status,
stderr = %String::from_utf8_lossy(&output.stderr),
"rsync template copy failed"
);
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to copy terraform template",
))
}
Err(error) => {
error!(%error, "failed to spawn rsync command");
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to copy terraform template",
))
}
}
}
async fn upload_tfvars(
cfg: &TerraformConfig,
remote_dir: &str,
content: &str,
) -> Result<(), Response> {
let remote_path = format!("{}/terraform.tfvars", remote_dir.trim_end_matches('/'));
let remote_path_escaped = shell_escape(&remote_path);
let mut command = Command::new("ssh");
command
.arg("-i")
.arg(&cfg.ssh_key_path)
.arg("-o")
.arg("StrictHostKeyChecking=no")
.arg("-o")
.arg("UserKnownHostsFile=/dev/null")
.arg("-o")
.arg("LogLevel=ERROR")
.arg(format!("{}@{}", cfg.ssh_user, cfg.ssh_host))
.arg(format!("cat > {remote_path_escaped}"))
.stdin(std::process::Stdio::piped());
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
error!(%error, "failed to spawn ssh for tfvars upload");
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to connect to terraform host",
));
}
};
if let Some(mut stdin) = child.stdin.take() {
if let Err(error) = stdin.write_all(content.as_bytes()).await {
error!(%error, "failed to write terraform.tfvars to remote host");
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to upload terraform file",
));
}
}
match child.wait().await {
Ok(status) if status.success() => Ok(()),
Ok(status) => {
warn!(return_code = status.code(), "remote cat command failed");
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to upload terraform file",
))
}
Err(error) => {
error!(%error, "failed to await ssh upload");
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to upload terraform file",
))
}
}
}
async fn run_ssh_command(cfg: &TerraformConfig, remote_cmd: &str) -> Result<(), Response> {
match execute_ssh(cfg, remote_cmd).await {
Ok(_) => Ok(()),
Err(output) => {
error!(
status = output.status,
stdout = %output.stdout,
stderr = %output.stderr,
"ssh command failed"
);
Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to execute command on terraform host",
))
}
}
}
#[derive(Debug)]
struct CommandOutput {
status: Option<i32>,
stdout: String,
stderr: String,
}
async fn execute_ssh(
cfg: &TerraformConfig,
remote_cmd: &str,
) -> Result<CommandOutput, CommandOutput> {
let mut command = Command::new("ssh");
command
.arg("-i")
.arg(&cfg.ssh_key_path)
.arg("-o")
.arg("StrictHostKeyChecking=no")
.arg("-o")
.arg("UserKnownHostsFile=/dev/null")
.arg(format!("{}@{}", cfg.ssh_user, cfg.ssh_host))
.arg(remote_cmd);
match command.output().await {
Ok(output) => {
let result = CommandOutput {
status: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
};
if output.status.success() {
Ok(result)
} else {
Err(result)
}
}
Err(error) => Err(CommandOutput {
status: None,
stdout: String::new(),
stderr: error.to_string(),
}),
}
}
async fn terraform_init(
cfg: &TerraformConfig,
remote_dir: &str,
upgrade: bool,
) -> Result<(), Response> {
let mut cmd = format!(
"cd {} && terraform init -input=false",
shell_escape(remote_dir)
);
if upgrade {
cmd.push_str(" -upgrade");
}
run_ssh_command(cfg, &cmd).await
}
fn shell_escape(input: &str) -> String {
let mut escaped = String::with_capacity(input.len() + 2);
escaped.push('\'');
for ch in input.chars() {
if ch == '\'' {
escaped.push_str("'\\''");
} else {
escaped.push(ch);
}
}
escaped.push('\'');
escaped
}
fn is_valid_formation_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
}

View File

@ -0,0 +1,170 @@
use chrono::{DateTime, Utc};
use serde::{de, Deserialize, Deserializer, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TerraformApplyRequest {
#[serde(alias = "formation_id")]
pub formation_id: String,
pub vms: Vec<TerraformVmRequest>,
#[serde(default)]
pub guacamole: Option<TerraformGuacamoleRequest>,
}
#[derive(Debug, Clone)]
pub struct TerraformVmRequest {
pub name: String,
pub vmid: u32,
pub cores: u8,
pub memory: u32,
pub disk_size: u32,
pub template: String,
pub ip: String,
pub model: TerraformVmOs,
pub gateway: Option<String>,
pub bridge: Option<String>,
pub rdp_username: Option<String>,
pub rdp_password: Option<String>,
pub rdp_domain: Option<String>,
pub rdp_port: Option<u16>,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
struct TerraformVmRequestHelper {
name: String,
vmid: u32,
cores: u8,
memory: u32,
#[serde(default)]
disk_size: Option<u32>,
#[serde(default, rename = "diskSize")]
disk_size_camel: Option<u32>,
#[serde(default, rename = "diskSizeGb")]
disk_size_gb: Option<u32>,
template: String,
ip: String,
#[serde(default, deserialize_with = "deserialize_vm_os")]
model: TerraformVmOs,
#[serde(default)]
gateway: Option<String>,
#[serde(default)]
bridge: Option<String>,
#[serde(default, rename = "rdpUsername")]
rdp_username: Option<String>,
#[serde(default, rename = "rdpPassword")]
rdp_password: Option<String>,
#[serde(default, rename = "rdpDomain")]
rdp_domain: Option<String>,
#[serde(default, rename = "rdpPort")]
rdp_port: Option<u16>,
}
#[derive(Debug, Clone)]
pub struct TerraformFormation {
pub id: Uuid,
pub formation_id: String,
pub remote_path: String,
pub guacamole_group_name: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TerraformGuacamoleRequest {
pub group_name: Option<String>,
#[serde(default)]
pub users: Vec<GuacamoleUserRequest>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GuacamoleUserRequest {
pub username: String,
pub password: String,
pub display_name: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TerraformFormationResponse {
pub id: Uuid,
pub formation_id: String,
pub remote_path: String,
pub guacamole_group_name: Option<String>,
pub created_at: DateTime<Utc>,
}
impl From<TerraformFormation> for TerraformFormationResponse {
fn from(value: TerraformFormation) -> Self {
Self {
id: value.id,
formation_id: value.formation_id,
remote_path: value.remote_path,
guacamole_group_name: value.guacamole_group_name,
created_at: value.created_at,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TerraformVmOs {
Windows,
#[default]
Linux,
}
fn deserialize_vm_os<'de, D>(deserializer: D) -> Result<TerraformVmOs, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<String> = Option::deserialize(deserializer)?;
match value.as_deref() {
Some(raw) => {
let normalized = raw.trim().to_ascii_lowercase();
match normalized.as_str() {
"windows" => Ok(TerraformVmOs::Windows),
"linux" => Ok(TerraformVmOs::Linux),
"" => Ok(TerraformVmOs::Linux),
other => Err(de::Error::custom(format!(
"unknown model '{other}' expected 'windows' or 'linux'"
))),
}
}
None => Ok(TerraformVmOs::Linux),
}
}
impl<'de> Deserialize<'de> for TerraformVmRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let helper = TerraformVmRequestHelper::deserialize(deserializer)?;
let disk_size = helper
.disk_size
.or(helper.disk_size_camel)
.or(helper.disk_size_gb)
.ok_or_else(|| de::Error::missing_field("disk_size"))?;
Ok(Self {
name: helper.name,
vmid: helper.vmid,
cores: helper.cores,
memory: helper.memory,
disk_size,
template: helper.template,
ip: helper.ip,
model: helper.model,
gateway: helper.gateway,
bridge: helper.bridge,
rdp_username: helper.rdp_username,
rdp_password: helper.rdp_password,
rdp_domain: helper.rdp_domain,
rdp_port: helper.rdp_port,
})
}
}

View File

@ -0,0 +1,100 @@
use std::time::Duration;
use anyhow::Context;
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use crate::config::TerraformConfig;
#[derive(Debug, Deserialize)]
struct QemuListResponse {
data: Vec<QemuVm>,
}
#[derive(Debug, Deserialize)]
struct QemuVm {
vmid: Option<u64>,
name: Option<String>,
template: Option<u8>,
#[serde(default)]
maxdisk: Option<u64>,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxmoxTemplate {
pub vmid: u64,
pub name: String,
pub disk_size_gb: u64,
}
pub async fn fetch_templates(cfg: &TerraformConfig) -> anyhow::Result<Vec<ProxmoxTemplate>> {
let url = format!(
"{}/nodes/{}/qemu?full=1",
cfg.proxmox_url.trim_end_matches('/'),
cfg.target_node
);
let client = build_client(cfg)?;
let response = client
.get(&url)
.header(
"Authorization",
format!(
"PVEAPIToken={}={}",
cfg.proxmox_token_id.trim(),
cfg.proxmox_token_secret.trim()
),
)
.send()
.await
.context("failed to contact proxmox api")?;
if response.status() == StatusCode::UNAUTHORIZED {
anyhow::bail!("proxmox api credentials rejected (401)");
}
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
anyhow::bail!("proxmox api returned {status}: {body}");
}
let payload: QemuListResponse = response
.json()
.await
.context("failed to deserialize proxmox response")?;
let templates = payload
.data
.into_iter()
.filter(|vm| vm.template.unwrap_or_default() == 1)
.filter_map(|vm| {
let vmid = vm.vmid?;
let name = vm.name.unwrap_or_else(|| format!("vm-{vmid}"));
let disk_size_gb = vm
.maxdisk
.map(|bytes| bytes / 1_073_741_824)
.unwrap_or_default();
Some(ProxmoxTemplate {
vmid,
name,
disk_size_gb,
})
})
.collect();
Ok(templates)
}
fn build_client(cfg: &TerraformConfig) -> anyhow::Result<Client> {
let builder = Client::builder()
.timeout(Duration::from_secs(20))
.danger_accept_invalid_certs(cfg.proxmox_insecure_tls);
builder
.build()
.context("failed to build proxmox reqwest client")
}

View File

@ -0,0 +1,80 @@
use anyhow::Result;
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); // defaults to Argon2id
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.to_string();
Ok(hash)
}
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed = PasswordHash::new(password_hash).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
email: String,
exp: u64,
iat: u64,
}
#[derive(Clone)]
pub struct JwtService {
encoding: EncodingKey,
decoding: DecodingKey,
ttl: u64,
}
impl JwtService {
pub fn new(secret: &str, ttl_seconds: u64) -> Self {
Self {
encoding: EncodingKey::from_secret(secret.as_bytes()),
decoding: DecodingKey::from_secret(secret.as_bytes()),
ttl: ttl_seconds,
}
}
pub fn issue(&self, user_id: &uuid::Uuid, email: &str) -> Result<String> {
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let exp = now + self.ttl;
let claims = Claims {
sub: user_id.to_string(),
email: email.to_string(),
exp,
iat: now,
};
let token = encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)?;
Ok(token)
}
pub fn validate(&self, token: &str) -> Result<TokenClaims> {
let data = decode::<Claims>(token, &self.decoding, &Validation::new(Algorithm::HS256))?;
let user_id = uuid::Uuid::parse_str(&data.claims.sub)?;
Ok(TokenClaims {
user_id,
email: data.claims.email,
})
}
}
#[derive(Debug, Clone)]
pub struct TokenClaims {
pub user_id: uuid::Uuid,
pub email: String,
}

View File

@ -0,0 +1,53 @@
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
#[derive(Default)]
struct Attempt {
count: u32,
lock_until: Option<Instant>,
}
#[derive(Default, Clone)]
pub struct AuthGuard {
inner: std::sync::Arc<Mutex<HashMap<String, Attempt>>>,
}
impl AuthGuard {
pub fn new() -> Self {
Self::default()
}
pub async fn is_locked(&self, key: &str) -> bool {
let map = self.inner.lock().await;
if let Some(a) = map.get(key) {
if let Some(until) = a.lock_until {
return Instant::now() < until;
}
}
false
}
pub async fn record_failure(&self, key: &str, max_attempts: u32, lockout_secs: u64) {
let mut map = self.inner.lock().await;
let entry = map.entry(key.to_string()).or_default();
let now = Instant::now();
if let Some(until) = entry.lock_until {
if now < until {
return;
}
}
entry.count = entry.count.saturating_add(1);
if entry.count >= max_attempts {
entry.lock_until = Some(now + Duration::from_secs(lockout_secs));
entry.count = 0; // reset after lock
}
}
pub async fn record_success(&self, key: &str) {
let mut map = self.inner.lock().await;
map.remove(key);
}
}

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 +1,3 @@
pub mod credentials;
pub mod guacamole;
pub mod pf_sense;
pub mod terraform;
pub mod auth;
pub mod bruteforce;
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,15 +1,83 @@
use crate::routes::deployments::DeploymentRequest;
use std::fmt::Write as _;
use crate::{
config::TerraformConfig,
routes::{
deployments::DeploymentRequest,
terraform::{TerraformApplyRequest, TerraformVmOs},
},
};
pub struct TfvarsGenerator;
impl TfvarsGenerator {
pub fn generate_vm_definitions(req: &TerraformApplyRequest, cfg: &TerraformConfig) -> String {
let mut buf = String::new();
let _ = writeln!(
buf,
"proxmox_url = \"{}\"",
escape(&cfg.proxmox_url)
);
let _ = writeln!(
buf,
"proxmox_token_id = \"{}\"",
escape(&cfg.proxmox_token_id)
);
let _ = writeln!(
buf,
"proxmox_token_secret = \"{}\"",
escape(&cfg.proxmox_token_secret)
);
let _ = writeln!(buf, "proxmox_insecure_tls = {}", cfg.proxmox_insecure_tls);
let _ = writeln!(
buf,
"target_node = \"{}\"",
escape(&cfg.target_node)
);
let _ = writeln!(buf, "model = {{");
let _ = writeln!(buf, " \"windows\" = \"{}\"", escape(&cfg.model_windows));
let _ = writeln!(buf, " \"linux\" = \"{}\"", escape(&cfg.model_linux));
let _ = writeln!(buf, "}}\n");
let _ = writeln!(buf, "vms = {{");
for vm in &req.vms {
let _ = writeln!(buf, " \"{}\" = {{", escape(&vm.name));
let _ = writeln!(buf, " vmid = {}", vm.vmid);
let _ = writeln!(buf, " cores = {}", vm.cores);
let _ = writeln!(buf, " memory = {}", vm.memory);
let _ = writeln!(buf, " disk_size = \"{}G\"", vm.disk_size);
let _ = writeln!(buf, " template = \"{}\"", escape(&vm.template));
let _ = writeln!(buf, " full_clone = false");
let gateway = "192.168.143.254";
let bridge = vm.bridge.as_deref().unwrap_or("GUACALAN");
let model_value = match vm.model {
TerraformVmOs::Windows => cfg.model_windows.as_str(),
TerraformVmOs::Linux => cfg.model_linux.as_str(),
};
let _ = writeln!(buf, " network_interfaces = [");
let _ = writeln!(buf, " {{");
let _ = writeln!(buf, " ip = \"{}\"", escape(&vm.ip));
let _ = writeln!(buf, " gateway = \"{}\"", escape(gateway));
let _ = writeln!(buf, " bridge = \"{}\"", escape(bridge));
let _ = writeln!(buf, " model = \"{}\"", escape(model_value));
let _ = writeln!(buf, " }}");
let _ = writeln!(buf, " ]");
let _ = writeln!(buf, " }}");
}
let _ = writeln!(buf, "}}");
buf
}
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))
.map(|i| format!("{}.{}", base_prefix, start as u32 + i))
.collect();
let mut buf = String::new();
@ -34,7 +102,7 @@ fn push_kv(buf: &mut String, key: &str, val: &str) {
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);
let _ = writeln!(buf, "{key} = {val}");
}
fn push_list(buf: &mut String, key: &str, values: &[String]) {
@ -44,7 +112,7 @@ fn push_list(buf: &mut String, key: &str, values: &[String]) {
.map(|v| format!("\"{}\"", escape(v)))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(buf, "{} = [{}]", key, inner);
let _ = writeln!(buf, "{key} = [{inner}]");
}
fn escape(s: &str) -> String {

View File

@ -0,0 +1,32 @@
services:
mysql:
image: mysql:8.0
container_name: apiprojetsolyti-mysql
restart: unless-stopped
env_file:
- .env
environment:
# Root password comes from .env (MYSQL_ROOT_PASSWORD)
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
# Ensure UTF-8 everywhere
- LANG=C.UTF-8
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./my.cnf:/etc/mysql/conf.d/my.cnf:ro
- ./init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD || exit 1"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
volumes:
mysql_data: {}
networks:
backend:
driver: bridge

View File

@ -0,0 +1,27 @@
-- Initialize application schema for apiprojetsolyti
-- Make sure MYSQL_DATABASE in .env is set to `apiprojetsolyti` to match this script
CREATE DATABASE IF NOT EXISTS `apiprojetsolyti` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE `apiprojetsolyti`;
-- Users table for the API
CREATE TABLE IF NOT EXISTS `users` (
`id` CHAR(36) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`password_hash` TEXT NOT NULL,
`display_name` VARCHAR(255) NULL,
`is_admin` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `terraform_formations` (
`id` CHAR(36) NOT NULL,
`formation_id` VARCHAR(255) NOT NULL,
`remote_path` VARCHAR(1024) NOT NULL,
`guacamole_group_name` VARCHAR(255) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_terraform_formations_formation` (`formation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

52
docker/mysql/my.cnf Normal file
View File

@ -0,0 +1,52 @@
[mysqld]
# Security-hardening and sensible defaults
user = mysql
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
datadir = /var/lib/mysql
# Do not allow LOAD DATA LOCAL INFILE (mitigate client local file reads)
local_infile = 0
# Disable symbolic-links for security
symbolic-links = 0
# Only allow secure file operations in a controlled directory
secure-file-priv = /var/lib/mysql-files
# Use modern authentication plugin (default in MySQL 8)
default_authentication_plugin = caching_sha2_password
# Character set
character-set-server = utf8mb4
collation-server = utf8mb4_0900_ai_ci
# SQL strict mode
sql_mode = STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
# Timezone
default_time_zone = "+00:00"
# Logging
log_error_verbosity = 2
slow_query_log = 1
long_query_time = 2
slow_query_log_file = /var/lib/mysql/slow.log
# InnoDB settings
innodb_file_per_table = 1
innodb_flush_log_at_trx_commit = 1
innodb_flush_method = O_DIRECT
# Connection limits
max_connections = 200
# Name resolution off for performance (use IPs or proper DNS)
skip-name-resolve
# TLS configuration (optional). To enforce TLS, uncomment below and provide certs
# require_secure_transport = ON
# ssl_ca = /etc/mysql/ssl/ca.pem
# ssl_cert = /etc/mysql/ssl/server-cert.pem
# ssl_key = /etc/mysql/ssl/server-key.pem

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
}