Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08268e6665 | |||
| 5e89b9a4d1 | |||
| 69648caa08 | |||
| 408093c7b6 | |||
| 5e633a6e56 | |||
| 1ddef6114c | |||
| 7c1d4b048c | |||
| d8c4982d49 | |||
| 0c0bb8deba | |||
| c909f37fad | |||
| 8dd2ac0265 | |||
| bba5834204 | |||
| 08612e7ce5 | |||
| 2c660b2d89 | |||
| 8e5b78ce77 | |||
| d08818a86c |
44
.env.example
Normal file
44
.env.example
Normal 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
1
.gitignore
vendored
@ -15,3 +15,4 @@ terraform_drop/
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
docker/
|
||||
|
||||
323
Cargo.lock
generated
323
Cargo.lock
generated
@ -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"
|
||||
|
||||
16
Cargo.toml
16
Cargo.toml
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
pub mod deployments;
|
||||
pub mod users;
|
||||
|
||||
pub use deployments::Deployment;
|
||||
pub use users::{GuacamoleUser, VmAssignment};
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,2 @@
|
||||
pub mod database;
|
||||
pub mod guacamole_client;
|
||||
pub mod pf_sense_client;
|
||||
pub mod terraform_executor;
|
||||
|
||||
pub use database::DatabasePool;
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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
5
crates/api/src/lib.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod bootstrap;
|
||||
pub mod config;
|
||||
pub mod infrastructure;
|
||||
pub mod routes;
|
||||
pub mod services;
|
||||
@ -1,6 +1,5 @@
|
||||
mod bootstrap;
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
pub mod routes;
|
||||
pub mod services;
|
||||
|
||||
472
crates/api/src/routes/admin/handlers.rs
Normal file
472
crates/api/src/routes/admin/handlers.rs
Normal 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(¤t_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)
|
||||
}
|
||||
4
crates/api/src/routes/admin/mod.rs
Normal file
4
crates/api/src/routes/admin/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
mod handlers;
|
||||
pub mod models;
|
||||
|
||||
pub use handlers::{create_user, dashboard, delete_user_handler, list, update_user_handler};
|
||||
51
crates/api/src/routes/admin/models.rs
Normal file
51
crates/api/src/routes/admin/models.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
144
crates/api/src/routes/app.rs
Normal file
144
crates/api/src/routes/app.rs
Normal 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(())
|
||||
}
|
||||
139
crates/api/src/routes/auth/db.rs
Normal file
139
crates/api/src/routes/auth/db.rs
Normal 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())
|
||||
}
|
||||
138
crates/api/src/routes/auth/handlers.rs
Normal file
138
crates/api/src/routes/auth/handlers.rs
Normal 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))
|
||||
}
|
||||
86
crates/api/src/routes/auth/helpers.rs
Normal file
86
crates/api/src/routes/auth/helpers.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
7
crates/api/src/routes/auth/mod.rs
Normal file
7
crates/api/src/routes/auth/mod.rs
Normal 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};
|
||||
69
crates/api/src/routes/auth/models.rs
Normal file
69
crates/api/src/routes/auth/models.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
36
crates/api/src/routes/cors.rs
Normal file
36
crates/api/src/routes/cors.rs
Normal 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))
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
98
crates/api/src/routes/terraform/db.rs
Normal file
98
crates/api/src/routes/terraform/db.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
847
crates/api/src/routes/terraform/guacamole.rs
Normal file
847
crates/api/src/routes/terraform/guacamole.rs
Normal 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
|
||||
}
|
||||
595
crates/api/src/routes/terraform/mod.rs
Normal file
595
crates/api/src/routes/terraform/mod.rs
Normal 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 == '_')
|
||||
}
|
||||
170
crates/api/src/routes/terraform/models.rs
Normal file
170
crates/api/src/routes/terraform/models.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
100
crates/api/src/routes/terraform/proxmox.rs
Normal file
100
crates/api/src/routes/terraform/proxmox.rs
Normal 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")
|
||||
}
|
||||
80
crates/api/src/services/auth.rs
Normal file
80
crates/api/src/services/auth.rs
Normal 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,
|
||||
}
|
||||
53
crates/api/src/services/bruteforce.rs
Normal file
53
crates/api/src/services/bruteforce.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
32
docker/mysql/docker-compose.yml
Normal file
32
docker/mysql/docker-compose.yml
Normal 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
|
||||
27
docker/mysql/init/01_schema.sql
Normal file
27
docker/mysql/init/01_schema.sql
Normal 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
52
docker/mysql/my.cnf
Normal 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
9
terraform.tfstate
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user