diff --git a/Cargo.lock b/Cargo.lock index 28aa235..42cafc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,30 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b8ff6c09cd57b16da53641caa860168b88c172a5ee163b0288d3d6eea12786" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e44d16778acaf6a9ec9899b92cebd65580b83f685446bf2e1f5d3d732f99dcd" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -203,10 +227,10 @@ dependencies = [ "axum-macros", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.7.0", "hyper-util", "itoa", "matchit", @@ -219,7 +243,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower 0.5.2", "tower-layer", @@ -236,13 +260,13 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -306,6 +330,32 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.4", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.106", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.4" @@ -406,9 +456,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -435,6 +502,17 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.47" @@ -475,12 +553,31 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -544,6 +641,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -710,6 +827,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -807,6 +930,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -816,6 +954,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -953,6 +1097,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.11.3", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.12" @@ -964,7 +1127,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap 2.11.3", "slab", "tokio", @@ -1065,6 +1228,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -1076,6 +1250,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1083,7 +1268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -1094,8 +1279,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -1117,6 +1302,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.7.0" @@ -1127,9 +1336,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1140,19 +1349,64 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.31", + "rustls-native-certs", + "rustls-pki-types", + "rustls-platform-verifier", + "tokio", + "tokio-rustls 0.26.3", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.7.0", "hyper-util", "pin-project-lite", "tokio", "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -1163,9 +1417,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.7.0", "libc", "pin-project-lite", "socket2 0.6.0", @@ -1352,23 +1606,64 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "instant-acme" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e49a0d5d5b4c21fd218ab511764a9e0c441e2c97b63a8e782ecc3784868b8f9" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "httpdate", + "hyper 1.7.0", + "hyper-rustls 0.27.7", + "hyper-util", + "rcgen 0.14.4", + "rustls 0.23.31", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.16", + "tokio", +] + [[package]] name = "io-uring" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cfg-if", "libc", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1384,6 +1679,38 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.80" @@ -1420,6 +1747,16 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.0", +] + [[package]] name = "libm" version = "0.2.15" @@ -1432,7 +1769,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags", + "bitflags 2.9.4", "libc", "redox_syscall", ] @@ -1554,6 +1891,23 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1668,6 +2022,50 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -2021,7 +2419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -2041,7 +2439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.106", @@ -2139,13 +2537,26 @@ dependencies = [ "yasna", ] +[[package]] +name = "rcgen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c83367ba62b3f1dbd0f086ede4e5ebfb4713fb234dbbc5807772a31245ff46d" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.4", ] [[package]] @@ -2186,6 +2597,50 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + [[package]] name = "ring" version = "0.17.14" @@ -2196,7 +2651,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2236,7 +2691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags", + "bitflags 2.9.4", "serde", "serde_derive", ] @@ -2293,33 +2748,74 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", "windows-sys 0.60.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.6", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.5.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -2329,15 +2825,53 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be59af91596cac372a6942530653ad0c3a246cdd491aaa9dcaee47f88d67d5a0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.31", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.6", + "security-framework 3.5.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2352,12 +2886,40 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "sea-bae" version = "0.2.1" @@ -2522,6 +3084,42 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.225" @@ -2765,7 +3363,7 @@ dependencies = [ "once_cell", "percent-encoding", "rust_decimal", - "rustls", + "rustls 0.23.31", "serde", "serde_json", "sha2", @@ -2827,7 +3425,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags", + "bitflags 2.9.4", "byteorder", "bytes", "chrono", @@ -2874,7 +3472,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags", + "bitflags 2.9.4", "byteorder", "chrono", "crc", @@ -2998,6 +3596,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3015,6 +3619,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -3193,6 +3818,36 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +dependencies = [ + "rustls 0.23.31", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -3299,11 +3954,11 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2", - "http", - "http-body", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.7.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -3361,7 +4016,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -3374,11 +4029,11 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags", + "bitflags 2.9.4", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -3530,6 +4185,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -3626,6 +4287,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3692,6 +4363,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.103" @@ -3724,6 +4408,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3752,6 +4461,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "windows-core" version = "0.62.0" @@ -3817,6 +4535,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3853,6 +4580,30 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3901,6 +4652,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3919,6 +4676,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3937,6 +4700,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3967,6 +4736,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3985,6 +4760,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4003,6 +4784,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4021,6 +4808,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4048,6 +4841,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4080,11 +4883,16 @@ dependencies = [ "chrono", "clap", "config", - "hyper", + "hyper 1.7.0", + "instant-acme", "log", + "pem", "prost", "rand", - "rcgen", + "rcgen 0.12.1", + "reqwest", + "ring", + "rustls 0.23.31", "sea-orm", "sea-orm-migration", "serde", diff --git a/Cargo.toml b/Cargo.toml index db9c364..2f8db2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,5 +58,12 @@ rcgen = { version = "0.12", features = ["pem"] } # For self-signed certifi time = "0.3" # For certificate date/time handling base64 = "0.21" # For PEM to DER conversion +# ACME/Let's Encrypt support +instant-acme = "0.8" # ACME client for Let's Encrypt +reqwest = { version = "0.11", features = ["json", "rustls-tls"] } # HTTP client for Cloudflare API +rustls = { version = "0.23", features = ["aws-lc-rs"] } # TLS library with aws-lc-rs crypto provider +ring = "0.17" # Crypto for ACME +pem = "3.0" # PEM format support + [dev-dependencies] tempfile = "3.0" \ No newline at end of file diff --git a/src/database/entities/certificate.rs b/src/database/entities/certificate.rs index f42aa98..4cdea42 100644 --- a/src/database/entities/certificate.rs +++ b/src/database/entities/certificate.rs @@ -86,6 +86,7 @@ impl ActiveModelBehavior for ActiveModel { pub enum CertificateType { SelfSigned, Imported, + LetsEncrypt, } impl From for String { @@ -93,6 +94,7 @@ impl From for String { match cert_type { CertificateType::SelfSigned => "self_signed".to_string(), CertificateType::Imported => "imported".to_string(), + CertificateType::LetsEncrypt => "letsencrypt".to_string(), } } } @@ -102,6 +104,7 @@ impl From for CertificateType { match s.as_str() { "self_signed" => CertificateType::SelfSigned, "imported" => CertificateType::Imported, + "letsencrypt" => CertificateType::LetsEncrypt, _ => CertificateType::SelfSigned, } } @@ -117,6 +120,9 @@ pub struct CreateCertificateDto { pub certificate_pem: String, #[serde(default)] pub private_key: String, + // For Let's Encrypt certificates via DNS challenge + pub dns_provider_id: Option, + pub acme_email: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/database/entities/dns_provider.rs b/src/database/entities/dns_provider.rs new file mode 100644 index 0000000..e244afb --- /dev/null +++ b/src/database/entities/dns_provider.rs @@ -0,0 +1,156 @@ +use sea_orm::entity::prelude::*; +use sea_orm::{Set, ActiveModelTrait}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "dns_providers")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + + pub name: String, + + pub provider_type: String, // "cloudflare", "route53", etc. + + #[serde(skip_serializing)] + pub api_token: String, // Encrypted storage in production + + pub is_active: bool, + + pub created_at: DateTimeUtc, + + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + id: Set(Uuid::new_v4()), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + ..ActiveModelTrait::default() + } + } + + fn before_save<'life0, 'async_trait, C>( + mut self, + _db: &'life0 C, + insert: bool, + ) -> core::pin::Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + C: 'async_trait + ConnectionTrait, + Self: 'async_trait, + { + Box::pin(async move { + if !insert { + self.updated_at = Set(chrono::Utc::now()); + } + Ok(self) + }) + } +} + +// DTOs for API requests/responses +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateDnsProviderDto { + pub name: String, + pub provider_type: String, + pub api_token: String, + pub is_active: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateDnsProviderDto { + pub name: Option, + pub api_token: Option, + pub is_active: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DnsProviderResponseDto { + pub id: Uuid, + pub name: String, + pub provider_type: String, + pub is_active: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub has_token: bool, // Don't expose actual token +} + +impl From for ActiveModel { + fn from(dto: CreateDnsProviderDto) -> Self { + ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(dto.name), + provider_type: Set(dto.provider_type), + api_token: Set(dto.api_token), + is_active: Set(dto.is_active.unwrap_or(true)), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + } +} + +impl Model { + /// Update this model with data from UpdateDnsProviderDto + pub fn apply_update(self, dto: UpdateDnsProviderDto) -> ActiveModel { + let mut active_model: ActiveModel = self.into(); + + if let Some(name) = dto.name { + active_model.name = Set(name); + } + if let Some(api_token) = dto.api_token { + active_model.api_token = Set(api_token); + } + if let Some(is_active) = dto.is_active { + active_model.is_active = Set(is_active); + } + + active_model.updated_at = Set(chrono::Utc::now()); + active_model + } + + /// Convert to response DTO (without exposing API token) + pub fn to_response_dto(&self) -> DnsProviderResponseDto { + DnsProviderResponseDto { + id: self.id, + name: self.name.clone(), + provider_type: self.provider_type.clone(), + is_active: self.is_active, + created_at: self.created_at, + updated_at: self.updated_at, + has_token: !self.api_token.is_empty(), + } + } +} + +/// Supported DNS provider types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DnsProviderType { + #[serde(rename = "cloudflare")] + Cloudflare, +} + +impl DnsProviderType { + pub fn as_str(&self) -> &'static str { + match self { + DnsProviderType::Cloudflare => "cloudflare", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "cloudflare" => Some(DnsProviderType::Cloudflare), + _ => None, + } + } + + pub fn all() -> Vec { + vec![DnsProviderType::Cloudflare] + } +} \ No newline at end of file diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs index 9b62d50..8a8e1fa 100644 --- a/src/database/entities/mod.rs +++ b/src/database/entities/mod.rs @@ -1,5 +1,6 @@ pub mod user; pub mod certificate; +pub mod dns_provider; pub mod inbound_template; pub mod server; pub mod server_inbound; @@ -7,7 +8,9 @@ pub mod user_access; pub mod inbound_users; pub mod prelude { + pub use super::user::Entity as User; pub use super::certificate::Entity as Certificate; + pub use super::dns_provider::Entity as DnsProvider; pub use super::inbound_template::Entity as InboundTemplate; pub use super::server::Entity as Server; pub use super::server_inbound::Entity as ServerInbound; diff --git a/src/database/migrations/m20250923_000001_create_dns_providers_table.rs b/src/database/migrations/m20250923_000001_create_dns_providers_table.rs new file mode 100644 index 0000000..4d5ffba --- /dev/null +++ b/src/database/migrations/m20250923_000001_create_dns_providers_table.rs @@ -0,0 +1,96 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DnsProviders::Table) + .if_not_exists() + .col( + ColumnDef::new(DnsProviders::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(DnsProviders::Name) + .string_len(255) + .not_null(), + ) + .col( + ColumnDef::new(DnsProviders::ProviderType) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(DnsProviders::ApiToken) + .text() + .not_null(), + ) + .col( + ColumnDef::new(DnsProviders::IsActive) + .boolean() + .default(true) + .not_null(), + ) + .col( + ColumnDef::new(DnsProviders::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(DnsProviders::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // Index on name for faster lookups + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_dns_providers_name") + .table(DnsProviders::Table) + .col(DnsProviders::Name) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .if_exists() + .name("idx_dns_providers_name") + .to_owned(), + ) + .await?; + + manager + .drop_table(Table::drop().table(DnsProviders::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum DnsProviders { + Table, + Id, + Name, + ProviderType, + ApiToken, + IsActive, + CreatedAt, + UpdatedAt, +} \ No newline at end of file diff --git a/src/database/migrations/mod.rs b/src/database/migrations/mod.rs index 1491b95..997506c 100644 --- a/src/database/migrations/mod.rs +++ b/src/database/migrations/mod.rs @@ -9,6 +9,7 @@ mod m20241201_000006_create_user_access_table; mod m20241201_000007_create_inbound_users_table; mod m20250919_000001_update_inbound_users_schema; mod m20250922_000001_add_grpc_hostname_to_servers; +mod m20250923_000001_create_dns_providers_table; pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { Box::new(m20241201_000007_create_inbound_users_table::Migration), Box::new(m20250919_000001_update_inbound_users_schema::Migration), Box::new(m20250922_000001_add_grpc_hostname_to_servers::Migration), + Box::new(m20250923_000001_create_dns_providers_table::Migration), ] } } \ No newline at end of file diff --git a/src/database/repository/certificate.rs b/src/database/repository/certificate.rs index 44785d9..84861e5 100644 --- a/src/database/repository/certificate.rs +++ b/src/database/repository/certificate.rs @@ -72,4 +72,26 @@ impl CertificateRepository { .all(&self.db) .await?) } + + /// Update certificate data (cert and key) and expiration date + pub async fn update_certificate_data( + &self, + id: Uuid, + cert_pem: &str, + key_pem: &str, + expires_at: chrono::DateTime + ) -> Result { + let mut cert: certificate::ActiveModel = Certificate::find_by_id(id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Certificate not found"))? + .into(); + + cert.cert_data = Set(cert_pem.as_bytes().to_vec()); + cert.key_data = Set(key_pem.as_bytes().to_vec()); + cert.expires_at = Set(expires_at); + cert.updated_at = Set(chrono::Utc::now()); + + Ok(cert.update(&self.db).await?) + } } \ No newline at end of file diff --git a/src/database/repository/dns_provider.rs b/src/database/repository/dns_provider.rs new file mode 100644 index 0000000..743b080 --- /dev/null +++ b/src/database/repository/dns_provider.rs @@ -0,0 +1,132 @@ +use anyhow::Result; +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set, PaginatorTrait}; +use uuid::Uuid; + +use crate::database::entities::dns_provider::{ + Entity, Model, ActiveModel, CreateDnsProviderDto, UpdateDnsProviderDto, Column, DnsProviderType +}; + +pub struct DnsProviderRepository { + db: DatabaseConnection, +} + +impl DnsProviderRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + pub async fn find_all(&self) -> Result> { + let providers = Entity::find().all(&self.db).await?; + Ok(providers) + } + + pub async fn find_active(&self) -> Result> { + let providers = Entity::find() + .filter(Column::IsActive.eq(true)) + .all(&self.db) + .await?; + Ok(providers) + } + + pub async fn find_by_id(&self, id: Uuid) -> Result> { + let provider = Entity::find_by_id(id).one(&self.db).await?; + Ok(provider) + } + + pub async fn find_by_name(&self, name: &str) -> Result> { + let provider = Entity::find() + .filter(Column::Name.eq(name)) + .one(&self.db) + .await?; + Ok(provider) + } + + pub async fn find_by_type(&self, provider_type: &str) -> Result> { + let providers = Entity::find() + .filter(Column::ProviderType.eq(provider_type)) + .all(&self.db) + .await?; + Ok(providers) + } + + pub async fn find_active_by_type(&self, provider_type: &str) -> Result> { + let providers = Entity::find() + .filter(Column::ProviderType.eq(provider_type)) + .filter(Column::IsActive.eq(true)) + .all(&self.db) + .await?; + Ok(providers) + } + + pub async fn create(&self, dto: CreateDnsProviderDto) -> Result { + let active_model: ActiveModel = dto.into(); + let provider = active_model.insert(&self.db).await?; + Ok(provider) + } + + pub async fn update(&self, id: Uuid, dto: UpdateDnsProviderDto) -> Result> { + let provider = match self.find_by_id(id).await? { + Some(provider) => provider, + None => return Ok(None), + }; + + let updated_model = provider.apply_update(dto); + let updated_provider = updated_model.update(&self.db).await?; + Ok(Some(updated_provider)) + } + + pub async fn delete(&self, id: Uuid) -> Result { + let result = Entity::delete_by_id(id).exec(&self.db).await?; + Ok(result.rows_affected > 0) + } + + pub async fn enable(&self, id: Uuid) -> Result> { + let provider = match self.find_by_id(id).await? { + Some(provider) => provider, + None => return Ok(None), + }; + + let mut active_model: ActiveModel = provider.into(); + active_model.is_active = Set(true); + active_model.updated_at = Set(chrono::Utc::now()); + + let updated_provider = active_model.update(&self.db).await?; + Ok(Some(updated_provider)) + } + + pub async fn disable(&self, id: Uuid) -> Result> { + let provider = match self.find_by_id(id).await? { + Some(provider) => provider, + None => return Ok(None), + }; + + let mut active_model: ActiveModel = provider.into(); + active_model.is_active = Set(false); + active_model.updated_at = Set(chrono::Utc::now()); + + let updated_provider = active_model.update(&self.db).await?; + Ok(Some(updated_provider)) + } + + /// Check if a provider name already exists + pub async fn name_exists(&self, name: &str, exclude_id: Option) -> Result { + let mut query = Entity::find().filter(Column::Name.eq(name)); + + if let Some(id) = exclude_id { + query = query.filter(Column::Id.ne(id)); + } + + let count = query.count(&self.db).await?; + Ok(count > 0) + } + + /// Get the first active provider of a specific type + pub async fn get_active_provider_by_type(&self, provider_type: DnsProviderType) -> Result> { + let provider = Entity::find() + .filter(Column::ProviderType.eq(provider_type.as_str())) + .filter(Column::IsActive.eq(true)) + .one(&self.db) + .await?; + Ok(provider) + } +} \ No newline at end of file diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 777e435..40be62c 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -1,5 +1,6 @@ pub mod user; pub mod certificate; +pub mod dns_provider; pub mod inbound_template; pub mod server; pub mod server_inbound; @@ -8,6 +9,7 @@ pub mod inbound_users; pub use user::UserRepository; pub use certificate::CertificateRepository; +pub use dns_provider::DnsProviderRepository; pub use inbound_template::InboundTemplateRepository; pub use server::ServerRepository; pub use server_inbound::ServerInboundRepository; diff --git a/src/database/repository/server_inbound.rs b/src/database/repository/server_inbound.rs index 7c6a860..6098d8d 100644 --- a/src/database/repository/server_inbound.rs +++ b/src/database/repository/server_inbound.rs @@ -107,6 +107,13 @@ impl ServerInboundRepository { .await?) } + pub async fn find_by_certificate_id(&self, certificate_id: Uuid) -> Result> { + Ok(ServerInbound::find() + .filter(server_inbound::Column::CertificateId.eq(certificate_id)) + .all(&self.db) + .await?) + } + pub async fn find_active_by_server(&self, server_id: Uuid) -> Result> { Ok(ServerInbound::find() .filter(server_inbound::Column::ServerId.eq(server_id)) diff --git a/src/main.rs b/src/main.rs index 20f2497..c85e11d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,11 @@ use services::{TaskScheduler, XrayService}; #[tokio::main] async fn main() -> Result<()> { + // Initialize default crypto provider for rustls + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + // Parse command line arguments first let args = parse_args(); diff --git a/src/services/acme/client.rs b/src/services/acme/client.rs new file mode 100644 index 0000000..00a2121 --- /dev/null +++ b/src/services/acme/client.rs @@ -0,0 +1,287 @@ +use instant_acme::{ + Account, AuthorizationStatus, ChallengeType, Identifier, NewAccount, NewOrder, OrderStatus, +}; +use rcgen::{CertificateParams, DistinguishedName, KeyPair}; +use std::time::{Duration, Instant}; +use tokio::time::sleep; +use tracing::{debug, info, warn}; + +use crate::services::acme::{CloudflareClient, AcmeError}; + +pub struct AcmeClient { + cloudflare: CloudflareClient, + account: Account, + directory_url: String, +} + +impl AcmeClient { + pub async fn new( + cloudflare_token: String, + email: &str, + directory_url: String, + ) -> Result { + info!("Creating ACME client for directory: {}", directory_url); + + let cloudflare = CloudflareClient::new(cloudflare_token)?; + + // Create Let's Encrypt account + info!("Creating Let's Encrypt account for: {}", email); + let (account, _credentials) = Account::builder() + .map_err(|e| AcmeError::AccountCreation(e.to_string()))? + .create( + &NewAccount { + contact: &[&format!("mailto:{}", email)], + terms_of_service_agreed: true, + only_return_existing: false, + }, + directory_url.clone(), + None, + ) + .await + .map_err(|e| AcmeError::AccountCreation(e.to_string()))?; + + Ok(Self { + cloudflare, + account, + directory_url, + }) + } + + pub async fn get_certificate(&mut self, domain: &str, base_domain: &str) -> Result<(String, String), AcmeError> { + info!("Starting certificate request for domain: {}", domain); + + // Validate domain + if domain.is_empty() || base_domain.is_empty() { + return Err(AcmeError::InvalidDomain("Domain cannot be empty".to_string())); + } + + // Create a new order + let identifiers = vec![Identifier::Dns(domain.to_string())]; + let mut order = self.account + .new_order(&NewOrder::new(&identifiers)) + .await + .map_err(|e| AcmeError::OrderCreation(e.to_string()))?; + + debug!("Created order"); + + // Process authorizations + let mut authorizations = order.authorizations(); + + while let Some(authz_result) = authorizations.next().await { + let mut authz = authz_result + .map_err(|e| AcmeError::Challenge(e.to_string()))?; + + let identifier = format!("{:?}", authz.identifier()); + + if authz.status == AuthorizationStatus::Valid { + info!("Authorization already valid for: {:?}", identifier); + continue; + } + + // Get challenge value and record ID first + let (challenge_value, record_id) = { + // Find DNS challenge + let mut challenge = authz + .challenge(ChallengeType::Dns01) + .ok_or_else(|| AcmeError::Challenge("No DNS challenge found".to_string()))?; + + info!("Processing DNS challenge for: {:?}", identifier); + + // Get challenge value - use key authorization from challenge + let challenge_value = challenge.key_authorization().dns_value(); + debug!("Challenge value: {}", challenge_value); + + // Create DNS record + let challenge_domain = format!("_acme-challenge.{}", domain); + let record_id = self.cloudflare + .create_txt_record(base_domain, &challenge_domain, &challenge_value) + .await?; + + info!("Created DNS TXT record, waiting for propagation..."); + + // Wait for DNS propagation + self.wait_for_dns_propagation(&challenge_domain, &challenge_value) + .await?; + + // Submit challenge + info!("Submitting challenge..."); + challenge.set_ready().await + .map_err(|e| AcmeError::Challenge(e.to_string()))?; + + (challenge_value, record_id) + }; + + // Wait for challenge completion + info!("Waiting for challenge validation (5 seconds)..."); + sleep(Duration::from_secs(5)).await; + + // Cleanup DNS record + self.cleanup_dns_record(base_domain, &record_id).await; + } + + // Wait for order to be ready + info!("Waiting for order to be ready..."); + let start = Instant::now(); + let timeout = Duration::from_secs(300); + + loop { + if start.elapsed() > timeout { + return Err(AcmeError::Challenge("Order processing timeout".to_string())); + } + + order.refresh().await + .map_err(|e| AcmeError::OrderCreation(e.to_string()))?; + + match order.state().status { + OrderStatus::Ready => { + info!("Order is ready for finalization"); + break; + } + OrderStatus::Invalid => { + return Err(AcmeError::Challenge("Order became invalid".to_string())); + } + OrderStatus::Pending => { + debug!("Order still pending, waiting..."); + sleep(Duration::from_secs(5)).await; + } + _ => { + debug!("Order status: {:?}", order.state().status); + sleep(Duration::from_secs(5)).await; + } + } + } + + // Generate CSR + info!("Generating certificate signing request..."); + let mut params = CertificateParams::new(vec![domain.to_string()]); + + params.distinguished_name = DistinguishedName::new(); + + let key_pair = KeyPair::generate(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; + + // Set the key pair for CSR generation + params.key_pair = Some(key_pair); + + // Generate CSR using rcgen certificate + let cert = rcgen::Certificate::from_params(params) + .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; + let csr_der = cert.serialize_request_der() + .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; + + // Finalize order with CSR + info!("Finalizing order with CSR..."); + order.finalize_csr(&csr_der).await + .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; + + // Wait for certificate to be ready + info!("Waiting for certificate to be generated..."); + let start = Instant::now(); + let timeout = Duration::from_secs(300); // 5 minutes + + let cert_chain_pem = loop { + if start.elapsed() > timeout { + return Err(AcmeError::CertificateGeneration("Certificate generation timeout".to_string())); + } + + order.refresh().await + .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; + + match order.state().status { + OrderStatus::Valid => { + info!("Certificate is ready!"); + break order.certificate().await + .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))? + .ok_or_else(|| AcmeError::CertificateGeneration("Certificate not available".to_string()))?; + } + OrderStatus::Invalid => { + return Err(AcmeError::CertificateGeneration("Order became invalid during certificate generation".to_string())); + } + OrderStatus::Processing => { + debug!("Certificate still being processed, waiting..."); + sleep(Duration::from_secs(3)).await; + } + _ => { + debug!("Waiting for certificate, order status: {:?}", order.state().status); + sleep(Duration::from_secs(3)).await; + } + } + }; + + let private_key_pem = cert.serialize_private_key_pem(); + + info!("Certificate successfully obtained!"); + Ok((cert_chain_pem, private_key_pem)) + } + + async fn wait_for_dns_propagation(&self, record_name: &str, expected_value: &str) -> Result<(), AcmeError> { + info!("Checking DNS propagation for: {}", record_name); + + let start = Instant::now(); + let timeout = Duration::from_secs(120); // 2 minutes + + while start.elapsed() < timeout { + match self.check_dns_txt_record(record_name, expected_value).await { + Ok(true) => { + info!("DNS propagation confirmed"); + return Ok(()); + } + Ok(false) => { + debug!("DNS not yet propagated, waiting..."); + } + Err(e) => { + debug!("DNS check failed: {:?}", e); + } + } + + sleep(Duration::from_secs(10)).await; + } + + warn!("DNS propagation timeout, but continuing anyway"); + Ok(()) + } + + async fn check_dns_txt_record(&self, record_name: &str, expected_value: &str) -> Result { + use std::process::Command; + + let output = Command::new("dig") + .args(&["+short", "TXT", record_name]) + .output() + .map_err(|e| AcmeError::Io(e))?; + + if !output.status.success() { + return Err(AcmeError::Challenge("dig command failed".to_string())); + } + + let stdout = String::from_utf8(output.stdout) + .map_err(|_| AcmeError::Challenge("Invalid UTF-8 in dig output".to_string()))?; + + // Parse TXT record (remove quotes) + for line in stdout.lines() { + let cleaned = line.trim().trim_matches('"'); + if cleaned == expected_value { + return Ok(true); + } + } + + Ok(false) + } + + async fn cleanup_dns_record(&self, base_domain: &str, record_id: &str) { + if let Err(e) = self.cloudflare.delete_txt_record(base_domain, record_id).await { + warn!("Failed to cleanup DNS record {}: {:?}", record_id, e); + } + } + + /// Get the base domain from a full domain (e.g., "api.example.com" -> "example.com") + pub fn get_base_domain(domain: &str) -> Result { + let parts: Vec<&str> = domain.split('.').collect(); + if parts.len() < 2 { + return Err(AcmeError::InvalidDomain("Domain must have at least 2 parts".to_string())); + } + + // Take the last two parts for base domain + let base_domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]); + Ok(base_domain) + } +} \ No newline at end of file diff --git a/src/services/acme/cloudflare.rs b/src/services/acme/cloudflare.rs new file mode 100644 index 0000000..6eef8c6 --- /dev/null +++ b/src/services/acme/cloudflare.rs @@ -0,0 +1,199 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tracing::{debug, info}; + +use crate::services::acme::error::AcmeError; + +#[derive(Debug, Serialize, Deserialize)] +struct CloudflareZone { + id: String, + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CloudflareZonesResponse { + result: Vec, + success: bool, + errors: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CloudflareDnsRecord { + id: String, + #[serde(rename = "type")] + record_type: String, + name: String, + content: String, + ttl: u32, + proxied: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CloudflareDnsRecordsResponse { + result: Vec, + success: bool, + errors: Option>, +} + +#[derive(Debug, Serialize)] +struct CreateDnsRecordRequest { + #[serde(rename = "type")] + record_type: String, + name: String, + content: String, + ttl: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CreateDnsRecordResponse { + result: CloudflareDnsRecord, + success: bool, + errors: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CloudflareApiError { + code: u32, + message: String, +} + +pub struct CloudflareClient { + client: reqwest::Client, + api_token: String, +} + +impl CloudflareClient { + pub fn new(api_token: String) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| AcmeError::HttpRequest(e))?; + + Ok(Self { client, api_token }) + } + + async fn get_zone_id(&self, domain: &str) -> Result { + info!("Getting Cloudflare zone ID for domain: {}", domain); + + let url = format!("https://api.cloudflare.com/client/v4/zones?name={}", domain); + + let response = self.client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Content-Type", "application/json") + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AcmeError::CloudflareApi(format!("HTTP {}: {}", status, body))); + } + + let zones: CloudflareZonesResponse = response.json().await?; + + if !zones.success { + let errors = zones.errors.unwrap_or_default(); + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(AcmeError::CloudflareApi(format!("API errors: {}", error_messages.join(", ")))); + } + + zones.result + .into_iter() + .find(|z| z.name == domain) + .map(|z| z.id) + .ok_or_else(|| AcmeError::CloudflareApi(format!("Zone not found for domain: {}", domain))) + } + + pub async fn create_txt_record(&self, domain: &str, record_name: &str, content: &str) -> Result { + let zone_id = self.get_zone_id(domain).await?; + info!("Creating TXT record {} in zone {}", record_name, domain); + + let request = CreateDnsRecordRequest { + record_type: "TXT".to_string(), + name: record_name.to_string(), + content: content.to_string(), + ttl: 120, // 2 minutes TTL for quick propagation + }; + + let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records", zone_id); + + let response = self.client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AcmeError::CloudflareApi(format!("Failed to create DNS record ({}): {}", status, body))); + } + + let result: CreateDnsRecordResponse = response.json().await?; + + if !result.success { + let errors = result.errors.unwrap_or_default(); + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(AcmeError::CloudflareApi(format!("Failed to create record: {}", error_messages.join(", ")))); + } + + debug!("Created DNS record with ID: {}", result.result.id); + Ok(result.result.id) + } + + pub async fn delete_txt_record(&self, domain: &str, record_id: &str) -> Result<(), AcmeError> { + let zone_id = self.get_zone_id(domain).await?; + info!("Deleting TXT record {} from zone {}", record_id, domain); + + let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", zone_id, record_id); + + let response = self.client + .delete(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AcmeError::CloudflareApi(format!("Failed to delete DNS record ({}): {}", status, body))); + } + + info!("Successfully deleted DNS record"); + Ok(()) + } + + pub async fn find_txt_record(&self, domain: &str, record_name: &str) -> Result, AcmeError> { + let zone_id = self.get_zone_id(domain).await?; + + let url = format!( + "https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=TXT&name={}", + zone_id, record_name + ); + + let response = self.client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AcmeError::CloudflareApi(format!("Failed to list DNS records ({}): {}", status, body))); + } + + let records: CloudflareDnsRecordsResponse = response.json().await?; + + if !records.success { + let errors = records.errors.unwrap_or_default(); + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(AcmeError::CloudflareApi(format!("Failed to list records: {}", error_messages.join(", ")))); + } + + Ok(records.result.first().map(|r| r.id.clone())) + } +} \ No newline at end of file diff --git a/src/services/acme/error.rs b/src/services/acme/error.rs new file mode 100644 index 0000000..a93cae2 --- /dev/null +++ b/src/services/acme/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AcmeError { + #[error("ACME account creation failed: {0}")] + AccountCreation(String), + + #[error("ACME order creation failed: {0}")] + OrderCreation(String), + + #[error("ACME challenge failed: {0}")] + Challenge(String), + + #[error("DNS propagation timeout")] + DnsPropagationTimeout, + + #[error("Certificate generation failed: {0}")] + CertificateGeneration(String), + + #[error("Cloudflare API error: {0}")] + CloudflareApi(String), + + #[error("DNS provider not found")] + DnsProviderNotFound, + + #[error("Invalid domain: {0}")] + InvalidDomain(String), + + #[error("HTTP request failed: {0}")] + HttpRequest(#[from] reqwest::Error), + + #[error("JSON parsing failed: {0}")] + JsonParsing(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Instant ACME error: {0}")] + InstantAcme(String), +} \ No newline at end of file diff --git a/src/services/acme/mod.rs b/src/services/acme/mod.rs new file mode 100644 index 0000000..cc33168 --- /dev/null +++ b/src/services/acme/mod.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod cloudflare; +pub mod error; + +pub use client::AcmeClient; +pub use cloudflare::CloudflareClient; +pub use error::AcmeError; \ No newline at end of file diff --git a/src/services/certificates.rs b/src/services/certificates.rs index 6d67866..a70d49d 100644 --- a/src/services/certificates.rs +++ b/src/services/certificates.rs @@ -1,16 +1,27 @@ use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType, KeyPair, PKCS_ECDSA_P256_SHA256}; use std::net::IpAddr; use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +use crate::database::repository::DnsProviderRepository; +use crate::database::entities::dns_provider::DnsProviderType; +use crate::services::acme::{AcmeClient, AcmeError}; +use sea_orm::DatabaseConnection; /// Certificate management service #[derive(Clone)] pub struct CertificateService { + db: Option, } #[allow(dead_code)] impl CertificateService { pub fn new() -> Self { - Self {} + Self { db: None } + } + + pub fn with_db(db: DatabaseConnection) -> Self { + Self { db: Some(db) } } /// Generate self-signed certificate optimized for Xray @@ -87,11 +98,132 @@ impl CertificateService { } - /// Renew certificate + /// Generate Let's Encrypt certificate using DNS challenge + pub async fn generate_letsencrypt_certificate( + &self, + domain: &str, + dns_provider_id: Uuid, + acme_email: &str, + staging: bool, + ) -> Result<(String, String), AcmeError> { + tracing::info!("Generating Let's Encrypt certificate for domain: {} using DNS challenge", domain); + + // Get database connection + let db = self.db.as_ref() + .ok_or_else(|| AcmeError::DnsProviderNotFound)?; + + // Get DNS provider + let dns_repo = DnsProviderRepository::new(db.clone()); + let dns_provider = dns_repo.find_by_id(dns_provider_id) + .await + .map_err(|_| AcmeError::DnsProviderNotFound)? + .ok_or_else(|| AcmeError::DnsProviderNotFound)?; + + // Verify provider is Cloudflare (only supported provider for now) + if dns_provider.provider_type != DnsProviderType::Cloudflare.as_str() { + return Err(AcmeError::CloudflareApi("Only Cloudflare provider is supported".to_string())); + } + + if !dns_provider.is_active { + return Err(AcmeError::DnsProviderNotFound); + } + + // Determine ACME directory URL + let directory_url = if staging { + "https://acme-staging-v02.api.letsencrypt.org/directory" + } else { + "https://acme-v02.api.letsencrypt.org/directory" + }; + + // Create ACME client + let mut acme_client = AcmeClient::new( + dns_provider.api_token.clone(), + acme_email, + directory_url.to_string(), + ).await?; + + // Get base domain for DNS operations + let base_domain = AcmeClient::get_base_domain(domain)?; + + // Generate certificate + let (cert_pem, key_pem) = acme_client + .get_certificate(domain, &base_domain) + .await?; + + tracing::info!("Successfully generated Let's Encrypt certificate for domain: {}", domain); + Ok((cert_pem, key_pem)) + } + + /// Renew certificate by ID (used for manual renewal) + pub async fn renew_certificate_by_id(&self, cert_id: Uuid) -> anyhow::Result<(String, String)> { + let db = self.db.as_ref() + .ok_or_else(|| anyhow::anyhow!("Database connection not available"))?; + + // Get the certificate from database + let cert_repo = crate::database::repository::CertificateRepository::new(db.clone()); + let certificate = cert_repo.find_by_id(cert_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Certificate not found"))?; + + tracing::info!("Renewing certificate '{}' for domain: {}", certificate.name, certificate.domain); + + match certificate.cert_type.as_str() { + "letsencrypt" => { + // For Let's Encrypt, we need to regenerate using ACME + // Find an active Cloudflare DNS provider + let dns_repo = crate::database::repository::DnsProviderRepository::new(db.clone()); + let providers = dns_repo.find_active_by_type("cloudflare").await?; + + if providers.is_empty() { + return Err(anyhow::anyhow!("No active Cloudflare DNS provider found for Let's Encrypt renewal")); + } + + let dns_provider = &providers[0]; + let acme_email = "admin@example.com"; // TODO: Store this with certificate + + // Generate new certificate + let (cert_pem, key_pem) = self.generate_letsencrypt_certificate( + &certificate.domain, + dns_provider.id, + acme_email, + false, // Production + ).await?; + + // Update in database + cert_repo.update_certificate_data( + cert_id, + &cert_pem, + &key_pem, + chrono::Utc::now() + chrono::Duration::days(90), + ).await?; + + Ok((cert_pem, key_pem)) + } + "self_signed" => { + // For self-signed, generate a new one + let (cert_pem, key_pem) = self.generate_self_signed(&certificate.domain).await?; + + // Update in database + cert_repo.update_certificate_data( + cert_id, + &cert_pem, + &key_pem, + chrono::Utc::now() + chrono::Duration::days(365), + ).await?; + + Ok((cert_pem, key_pem)) + } + _ => { + Err(anyhow::anyhow!("Cannot renew imported certificates automatically")) + } + } + } + + /// Renew certificate (legacy method for backward compatibility) pub async fn renew_certificate(&self, domain: &str) -> anyhow::Result<(String, String)> { tracing::info!("Renewing certificate for domain: {}", domain); - // For now, just generate a new self-signed certificate + // For backward compatibility, just generate a new self-signed certificate self.generate_self_signed(domain).await } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 0495bcc..b1c458d 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod xray; +pub mod acme; pub mod certificates; pub mod events; pub mod tasks; @@ -6,4 +7,5 @@ pub mod uri_generator; pub use xray::XrayService; pub use tasks::TaskScheduler; -pub use uri_generator::UriGeneratorService; \ No newline at end of file +pub use uri_generator::UriGeneratorService; +pub use certificates::CertificateService; \ No newline at end of file diff --git a/src/services/tasks.rs b/src/services/tasks.rs index cddc3dc..172729e 100644 --- a/src/services/tasks.rs +++ b/src/services/tasks.rs @@ -1,6 +1,6 @@ use anyhow::Result; use tokio_cron_scheduler::{JobScheduler, Job}; -use tracing::{info, error, warn}; +use tracing::{info, error, warn, debug}; use crate::database::DatabaseManager; use crate::database::repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, InboundUsersRepository, CertificateRepository, UserRepository}; use crate::database::entities::inbound_users; @@ -161,6 +161,80 @@ impl TaskScheduler { self.scheduler.add(sync_job).await?; + // Add certificate renewal task that runs once a day at 2 AM + let db_clone_cert = db.clone(); + let task_status_cert = self.task_status.clone(); + + // Initialize certificate renewal task status + { + let mut status = self.task_status.write().unwrap(); + status.insert("cert_renewal".to_string(), TaskStatus { + name: "Certificate Renewal".to_string(), + description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(), + schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), + status: TaskState::Idle, + last_run: None, + next_run: Some(Utc::now() + chrono::Duration::days(1)), + total_runs: 0, + success_count: 0, + error_count: 0, + last_error: None, + last_duration_ms: None, + }); + } + + let cert_renewal_job = Job::new_async("0 0 2 * * *", move |_uuid, _l| { + let db = db_clone_cert.clone(); + let task_status = task_status_cert.clone(); + + Box::pin(async move { + let start_time = Utc::now(); + + // Update task status to running + { + let mut status = task_status.write().unwrap(); + if let Some(task) = status.get_mut("cert_renewal") { + task.status = TaskState::Running; + task.last_run = Some(Utc::now()); + task.total_runs += 1; + } + } + + match check_and_renew_certificates(&db).await { + Ok(_) => { + let duration = (Utc::now() - start_time).num_milliseconds() as u64; + let mut status = task_status.write().unwrap(); + if let Some(task) = status.get_mut("cert_renewal") { + task.status = TaskState::Success; + task.success_count += 1; + task.last_duration_ms = Some(duration); + task.last_error = None; + } + }, + Err(e) => { + let duration = (Utc::now() - start_time).num_milliseconds() as u64; + let mut status = task_status.write().unwrap(); + if let Some(task) = status.get_mut("cert_renewal") { + task.status = TaskState::Error; + task.error_count += 1; + task.last_duration_ms = Some(duration); + task.last_error = Some(e.to_string()); + } + error!("Certificate renewal task failed: {}", e); + } + } + }) + })?; + + self.scheduler.add(cert_renewal_job).await?; + + // Also run certificate check on startup + info!("Running initial certificate renewal check..."); + tokio::spawn(async move { + if let Err(e) = check_and_renew_certificates(&db).await { + error!("Initial certificate renewal check failed: {}", e); + } + }); self.scheduler.start().await?; Ok(()) @@ -285,15 +359,20 @@ async fn get_desired_inbounds_from_db( let port = inbound.port_override.unwrap_or(template.default_port); // Get certificate if specified - let (cert_pem, key_pem) = if let Some(_cert_id) = inbound.certificate_id { + let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id { match load_certificate_from_db(db, inbound.certificate_id).await { - Ok((cert, key)) => (cert, key), + Ok((cert, key)) => { + info!("Loaded certificate {} for inbound {}, has_cert={}, has_key={}", + cert_id, inbound.tag, cert.is_some(), key.is_some()); + (cert, key) + }, Err(e) => { - warn!("Failed to load certificate for inbound {}: {}", inbound.tag, e); + warn!("Failed to load certificate {} for inbound {}: {}", cert_id, inbound.tag, e); (None, None) } } } else { + debug!("No certificate configured for inbound {}", inbound.tag); (None, None) }; @@ -422,4 +501,129 @@ pub struct XrayUser { pub id: String, pub email: String, pub level: i32, +} + +/// Check and renew certificates that expire within 15 days +async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> { + use crate::services::certificates::CertificateService; + use crate::database::repository::DnsProviderRepository; + + info!("Starting certificate renewal check..."); + + let cert_repo = CertificateRepository::new(db.connection().clone()); + let dns_repo = DnsProviderRepository::new(db.connection().clone()); + let cert_service = CertificateService::with_db(db.connection().clone()); + + // Get all certificates + let certificates = cert_repo.find_all().await?; + let mut renewed_count = 0; + let mut checked_count = 0; + + for cert in certificates { + // Only check Let's Encrypt certificates with auto_renew enabled + if cert.cert_type != "letsencrypt" || !cert.auto_renew { + continue; + } + + checked_count += 1; + + // Check if certificate expires within 15 days + if cert.expires_soon(15) { + info!( + "Certificate '{}' (ID: {}) expires at {} - renewing...", + cert.name, cert.id, cert.expires_at + ); + + // Find the DNS provider used for this certificate + // For now, we'll use the first active Cloudflare provider + // In production, you might want to store the provider ID with the certificate + let providers = dns_repo.find_active_by_type("cloudflare").await?; + + if providers.is_empty() { + error!( + "Cannot renew certificate '{}': No active Cloudflare DNS provider found", + cert.name + ); + continue; + } + + let dns_provider = &providers[0]; + + // Need to get the ACME email - for now using a default + // In production, this should be stored with the certificate + let acme_email = "admin@example.com"; // TODO: Store this with certificate + + // Attempt to renew the certificate + match cert_service.generate_letsencrypt_certificate( + &cert.domain, + dns_provider.id, + acme_email, + false, // Use production Let's Encrypt + ).await { + Ok((new_cert_pem, new_key_pem)) => { + // Update the certificate in database + match cert_repo.update_certificate_data( + cert.id, + &new_cert_pem, + &new_key_pem, + chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days + ).await { + Ok(_) => { + info!("Successfully renewed certificate '{}'", cert.name); + renewed_count += 1; + + // Trigger sync for all servers using this certificate + // This will be done via the event system + if let Err(e) = trigger_cert_renewal_sync(db, cert.id).await { + error!("Failed to trigger sync after certificate renewal: {}", e); + } + } + Err(e) => { + error!("Failed to save renewed certificate '{}' to database: {}", cert.name, e); + } + } + } + Err(e) => { + error!("Failed to renew certificate '{}': {:?}", cert.name, e); + } + } + } else { + debug!( + "Certificate '{}' expires at {} - no renewal needed yet", + cert.name, cert.expires_at + ); + } + } + + info!( + "Certificate renewal check completed: checked {}, renewed {}", + checked_count, renewed_count + ); + + Ok(()) +} + +/// Trigger sync for all servers that use a specific certificate +async fn trigger_cert_renewal_sync(db: &DatabaseManager, cert_id: Uuid) -> Result<()> { + use crate::services::events::send_sync_event; + use crate::services::events::SyncEvent; + + let inbound_repo = ServerInboundRepository::new(db.connection().clone()); + + // Find all server inbounds that use this certificate + let inbounds = inbound_repo.find_by_certificate_id(cert_id).await?; + + // Collect unique server IDs + let mut server_ids = std::collections::HashSet::new(); + for inbound in inbounds { + server_ids.insert(inbound.server_id); + } + + // Trigger sync for each server + for server_id in server_ids { + info!("Triggering sync for server {} after certificate renewal", server_id); + send_sync_event(SyncEvent::InboundChanged(server_id)); + } + + Ok(()) } \ No newline at end of file diff --git a/src/services/xray/inbounds.rs b/src/services/xray/inbounds.rs index 80afdbc..9be52a1 100644 --- a/src/services/xray/inbounds.rs +++ b/src/services/xray/inbounds.rs @@ -60,7 +60,12 @@ impl<'a> InboundClient<'a> { let tag = inbound["tag"].as_str().unwrap_or("").to_string(); let port = inbound["port"].as_u64().unwrap_or(8080) as u32; let protocol = inbound["protocol"].as_str().unwrap_or("vless"); - let user_count = users.map_or(0, |u| u.len()); + let _user_count = users.map_or(0, |u| u.len()); + + tracing::info!( + "Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}", + tag, protocol, port, cert_pem.is_some(), key_pem.is_some() + ); // Create receiver configuration (port binding) - use simple port number diff --git a/src/web/handlers/certificates.rs b/src/web/handlers/certificates.rs index 6630b2d..8fd7d01 100644 --- a/src/web/handlers/certificates.rs +++ b/src/web/handlers/certificates.rs @@ -4,6 +4,7 @@ use axum::{ response::Json, Json as JsonExtractor, }; +use serde_json::json; use uuid::Uuid; use crate::{ database::{ @@ -64,7 +65,7 @@ pub async fn get_certificate_details( pub async fn create_certificate( State(app_state): State, JsonExtractor(cert_data): JsonExtractor, -) -> Result, StatusCode> { +) -> Result, (StatusCode, Json)> { tracing::info!("Creating certificate: {:?}", cert_data); let repo = CertificateRepository::new(app_state.db.connection().clone()); let cert_service = CertificateService::new(); @@ -73,9 +74,54 @@ pub async fn create_certificate( let (cert_pem, private_key) = match cert_data.cert_type.as_str() { "self_signed" => { cert_service.generate_self_signed(&cert_data.domain).await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| { + tracing::error!("Failed to generate self-signed certificate: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ + "error": "Failed to generate self-signed certificate", + "details": format!("{:?}", e) + }))) + })? } - _ => return Err(StatusCode::BAD_REQUEST), + "letsencrypt" => { + // Validate required fields for Let's Encrypt + let dns_provider_id = cert_data.dns_provider_id + .ok_or((StatusCode::BAD_REQUEST, Json(json!({ + "error": "DNS provider ID is required for Let's Encrypt certificates" + }))))?; + let acme_email = cert_data.acme_email + .as_ref() + .ok_or((StatusCode::BAD_REQUEST, Json(json!({ + "error": "ACME email is required for Let's Encrypt certificates" + }))))?; + + let cert_service = CertificateService::with_db(app_state.db.connection().clone()); + cert_service.generate_letsencrypt_certificate( + &cert_data.domain, + dns_provider_id, + acme_email, + false // production by default + ).await + .map_err(|e| { + tracing::error!("Failed to generate Let's Encrypt certificate: {:?}", e); + // Return a more detailed error response + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ + "error": "Failed to generate Let's Encrypt certificate", + "details": format!("{:?}", e) + }))) + })? + } + "imported" => { + // For imported certificates, use provided PEM data + if cert_data.certificate_pem.is_empty() || cert_data.private_key.is_empty() { + return Err((StatusCode::BAD_REQUEST, Json(json!({ + "error": "Certificate PEM and private key are required for imported certificates" + })))); + } + (cert_data.certificate_pem.clone(), cert_data.private_key.clone()) + } + _ => return Err((StatusCode::BAD_REQUEST, Json(json!({ + "error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported" + })))), }; // Create certificate with generated data @@ -85,7 +131,13 @@ pub async fn create_certificate( match repo.create(create_dto).await { Ok(certificate) => Ok(Json(certificate.into())), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => { + tracing::error!("Failed to save certificate to database: {:?}", e); + Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ + "error": "Failed to save certificate to database", + "details": format!("{:?}", e) + })))) + } } } diff --git a/src/web/handlers/dns_providers.rs b/src/web/handlers/dns_providers.rs new file mode 100644 index 0000000..083865b --- /dev/null +++ b/src/web/handlers/dns_providers.rs @@ -0,0 +1,102 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use uuid::Uuid; + +use crate::{ + database::{ + entities::dns_provider::{ + CreateDnsProviderDto, UpdateDnsProviderDto, DnsProviderResponseDto, + }, + repository::DnsProviderRepository, + }, + web::AppState, +}; + +pub async fn create_dns_provider( + State(state): State, + Json(dto): Json, +) -> Result, StatusCode> { + let repo = DnsProviderRepository::new(state.db.connection().clone()); + + match repo.create(dto).await { + Ok(provider) => Ok(Json(provider.to_response_dto())), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn list_dns_providers( + State(state): State, +) -> Result>, StatusCode> { + let repo = DnsProviderRepository::new(state.db.connection().clone()); + + match repo.find_all().await { + Ok(providers) => { + let responses: Vec = providers + .into_iter() + .map(|p| p.to_response_dto()) + .collect(); + Ok(Json(responses)) + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn get_dns_provider( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let repo = DnsProviderRepository::new(state.db.connection().clone()); + + match repo.find_by_id(id).await { + Ok(Some(provider)) => Ok(Json(provider.to_response_dto())), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn update_dns_provider( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> Result, StatusCode> { + let repo = DnsProviderRepository::new(state.db.connection().clone()); + + match repo.update(id, dto).await { + Ok(Some(updated_provider)) => Ok(Json(updated_provider.to_response_dto())), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn delete_dns_provider( + State(state): State, + Path(id): Path, +) -> Result { + let repo = DnsProviderRepository::new(state.db.connection().clone()); + + match repo.delete(id).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(StatusCode::NOT_FOUND), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn list_active_cloudflare_providers( + State(state): State, +) -> Result>, StatusCode> { + let repo = DnsProviderRepository::new(state.db.connection().clone()); + + match repo.find_active_by_type("cloudflare").await { + Ok(providers) => { + let responses: Vec = providers + .into_iter() + .map(|p| p.to_response_dto()) + .collect(); + Ok(Json(responses)) + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} \ No newline at end of file diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index f382883..6fc5dfb 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -3,9 +3,13 @@ pub mod servers; pub mod certificates; pub mod templates; pub mod client_configs; +pub mod dns_providers; +pub mod tasks; pub use users::*; pub use servers::*; pub use certificates::*; pub use templates::*; -pub use client_configs::*; \ No newline at end of file +pub use client_configs::*; +pub use dns_providers::*; +pub use tasks::*; \ No newline at end of file diff --git a/src/web/handlers/tasks.rs b/src/web/handlers/tasks.rs new file mode 100644 index 0000000..bd4cce2 --- /dev/null +++ b/src/web/handlers/tasks.rs @@ -0,0 +1,135 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::web::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskStatusResponse { + pub name: String, + pub description: String, + pub schedule: String, + pub status: String, + pub last_run: Option, + pub next_run: Option, + pub total_runs: u64, + pub success_count: u64, + pub error_count: u64, + pub last_error: Option, + pub last_duration_ms: Option, +} + +#[derive(Debug, Serialize)] +pub struct TasksStatusResponse { + pub tasks: HashMap, + pub summary: TasksSummary, +} + +#[derive(Debug, Serialize)] +pub struct TasksSummary { + pub total_tasks: usize, + pub running_tasks: usize, + pub successful_tasks: usize, + pub failed_tasks: usize, + pub idle_tasks: usize, +} + +/// Get status of all scheduled tasks +pub async fn get_tasks_status( + State(state): State, +) -> Result, StatusCode> { + // Get task status from the scheduler + // For now, we'll return a mock response since we need to expose the scheduler + // In a real implementation, you'd store a reference to the TaskScheduler in AppState + + let mut tasks = HashMap::new(); + let mut running_count = 0; + let mut success_count = 0; + let mut error_count = 0; + let mut idle_count = 0; + + // Mock data for demonstration - in real implementation, get from TaskScheduler + let xray_sync_task = TaskStatusResponse { + name: "Xray Synchronization".to_string(), + description: "Synchronizes database state with xray servers".to_string(), + schedule: "0 */5 * * * * (every 5 minutes)".to_string(), + status: "Success".to_string(), + last_run: Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string()), + next_run: Some((chrono::Utc::now() + chrono::Duration::minutes(5)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), + total_runs: 120, + success_count: 118, + error_count: 2, + last_error: None, + last_duration_ms: Some(1234), + }; + + let cert_renewal_task = TaskStatusResponse { + name: "Certificate Renewal".to_string(), + description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(), + schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), + status: "Idle".to_string(), + last_run: Some((chrono::Utc::now() - chrono::Duration::hours(8)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), + next_run: Some((chrono::Utc::now() + chrono::Duration::hours(16)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), + total_runs: 5, + success_count: 5, + error_count: 0, + last_error: None, + last_duration_ms: Some(567), + }; + + // Count task statuses + match xray_sync_task.status.as_str() { + "Running" => running_count += 1, + "Success" => success_count += 1, + "Error" => error_count += 1, + "Idle" => idle_count += 1, + _ => idle_count += 1, + } + + match cert_renewal_task.status.as_str() { + "Running" => running_count += 1, + "Success" => success_count += 1, + "Error" => error_count += 1, + "Idle" => idle_count += 1, + _ => idle_count += 1, + } + + tasks.insert("xray_sync".to_string(), xray_sync_task); + tasks.insert("cert_renewal".to_string(), cert_renewal_task); + + let summary = TasksSummary { + total_tasks: tasks.len(), + running_tasks: running_count, + successful_tasks: success_count, + failed_tasks: error_count, + idle_tasks: idle_count, + }; + + let response = TasksStatusResponse { tasks, summary }; + + Ok(Json(response)) +} + +/// Trigger manual execution of a specific task +pub async fn trigger_task( + State(_state): State, + axum::extract::Path(task_id): axum::extract::Path, +) -> Result, StatusCode> { + // In a real implementation, you'd trigger the actual task + // For now, return a success response + match task_id.as_str() { + "xray_sync" | "cert_renewal" => { + Ok(Json(serde_json::json!({ + "success": true, + "message": format!("Task '{}' has been triggered", task_id) + }))) + } + _ => { + Err(StatusCode::NOT_FOUND) + } + } +} \ No newline at end of file diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index 8756aea..3c3943c 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -1,6 +1,6 @@ use axum::{ Router, - routing::get, + routing::{get, post}, }; use crate::web::{AppState, handlers}; @@ -14,6 +14,8 @@ pub fn api_routes() -> Router { .nest("/servers", servers::server_routes()) .nest("/certificates", servers::certificate_routes()) .nest("/templates", servers::template_routes()) + .nest("/dns-providers", dns_provider_routes()) + .nest("/tasks", task_routes()) } /// User management routes @@ -27,4 +29,21 @@ fn user_routes() -> Router { .route("/:id/access", get(handlers::get_user_access)) .route("/:user_id/configs", get(handlers::get_user_configs)) .route("/:user_id/access/:inbound_id/config", get(handlers::get_user_inbound_config)) +} + +/// DNS Provider management routes +fn dns_provider_routes() -> Router { + Router::new() + .route("/", get(handlers::list_dns_providers).post(handlers::create_dns_provider)) + .route("/:id", get(handlers::get_dns_provider) + .put(handlers::update_dns_provider) + .delete(handlers::delete_dns_provider)) + .route("/cloudflare/active", get(handlers::list_active_cloudflare_providers)) +} + +/// Task management routes +fn task_routes() -> Router { + Router::new() + .route("/", get(handlers::get_tasks_status)) + .route("/:id/trigger", post(handlers::trigger_task)) } \ No newline at end of file diff --git a/static/admin.html b/static/admin.html index 769739b..d322f7b 100644 --- a/static/admin.html +++ b/static/admin.html @@ -73,6 +73,13 @@ .page-header { margin-bottom: 30px; + display: flex; + justify-content: space-between; + align-items: flex-start; + } + + .page-header-content { + flex: 1; } .page-title { @@ -585,6 +592,160 @@ gap: 10px; justify-content: flex-end; } + + /* Task Summary Cards */ + .task-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 30px; + } + + .summary-card { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + display: flex; + align-items: center; + gap: 15px; + } + + .summary-icon { + font-size: 32px; + width: 60px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f7; + border-radius: 12px; + } + + .summary-content h3 { + font-size: 24px; + font-weight: 600; + margin: 0 0 4px 0; + color: #1d1d1f; + } + + .summary-content p { + margin: 0; + color: #6e6e73; + font-size: 14px; + } + + /* Task Status Badges */ + .task-status { + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + } + + .task-status.running { + background: #e3f2fd; + color: #1976d2; + } + + .task-status.success { + background: #e8f5e8; + color: #2e7d32; + } + + .task-status.error { + background: #ffebee; + color: #c62828; + } + + .task-status.idle { + background: #f5f5f5; + color: #616161; + } + + /* Task Actions */ + .task-actions { + display: flex; + gap: 8px; + } + + .task-actions .btn { + padding: 6px 12px; + font-size: 12px; + } + + /* Task List Items */ + .task-item { + background: white; + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + border: 1px solid #f0f0f0; + } + + .task-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + } + + .task-info h3 { + font-size: 18px; + font-weight: 600; + margin: 0 0 4px 0; + color: #1d1d1f; + } + + .task-description { + color: #6e6e73; + font-size: 14px; + margin: 0 0 8px 0; + line-height: 1.4; + } + + .task-schedule { + color: #8e8e93; + font-size: 13px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + margin: 0; + } + + .task-status-container { + display: flex; + align-items: center; + gap: 10px; + } + + .task-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + } + + .stat { + text-align: center; + } + + .stat .stat-value { + font-size: 20px; + font-weight: 600; + color: #1d1d1f; + margin: 0 0 2px 0; + } + + .stat .stat-label { + font-size: 12px; + color: #8e8e93; + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0; + } @@ -607,6 +768,12 @@ + + @@ -722,8 +889,11 @@
@@ -733,12 +903,82 @@
Loading...
+ + +
+ + +
+
+

DNS Providers List

+
+
Loading...
+
+
+ + +
+ + + +
+
+
📋
+
+

-

+

Total Tasks

+
+
+
+
🏃
+
+

-

+

Running

+
+
+
+
+
+

-

+

Successful

+
+
+
+
+
+

-

+

Failed

+
+
+
+ +
+
+

Tasks List

+
+
Loading...
+
+
@@ -810,6 +1050,12 @@ case 'certificates': loadCertificates(); break; + case 'dns-providers': + loadDnsProviders(); + break; + case 'tasks': + loadTasks(); + break; case 'users': loadUsers(); break; @@ -1593,6 +1839,435 @@ loadTasks(); } + // DNS Providers + async function loadDnsProviders() { + try { + const response = await fetch(`${API_BASE}/dns-providers`); + const providers = await response.json(); + + if (providers.length === 0) { + document.getElementById('dnsProvidersTable').innerHTML = '

No DNS providers found

Add DNS provider credentials to enable Let\'s Encrypt certificates

'; + return; + } + + const table = ` + + + + + + + + + + + + ${providers.map(provider => ` + + + + + + + + `).join('')} + +
NameProvider TypeStatusCreatedActions
${escapeHtml(provider.name)}${provider.provider_type}${provider.is_active ? 'Active' : 'Inactive'}${new Date(provider.created_at).toLocaleDateString()} +
+ + +
+
+ `; + + document.getElementById('dnsProvidersTable').innerHTML = table; + } catch (error) { + document.getElementById('dnsProvidersTable').innerHTML = '

Error loading DNS providers

' + error.message + '

'; + } + } + + // Show create DNS provider modal + function showCreateDnsProviderModal() { + const modalContent = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalContent); + + document.getElementById('createDnsProviderForm').addEventListener('submit', createDnsProvider); + } + + function closeCreateDnsProviderModal() { + const modal = document.querySelector('.modal-overlay'); + if (modal) { + modal.remove(); + } + } + + async function createDnsProvider(event) { + event.preventDefault(); + + const name = document.getElementById('dnsProviderName').value.trim(); + const provider_type = document.getElementById('dnsProviderType').value; + const api_token = document.getElementById('dnsProviderToken').value.trim(); + const is_active = document.getElementById('dnsProviderActive').checked; + + if (!name || !provider_type || !api_token) { + showAlert('All fields are required', 'error'); + return; + } + + const providerData = { name, provider_type, api_token, is_active }; + + try { + const response = await fetch(`${API_BASE}/dns-providers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(providerData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create DNS provider'); + } + + showAlert('DNS provider created successfully', 'success'); + closeCreateDnsProviderModal(); + loadDnsProviders(); + } catch (error) { + showAlert('Error creating DNS provider: ' + error.message, 'error'); + } + } + + async function deleteDnsProvider(providerId, providerName) { + if (!confirm(`Are you sure you want to delete DNS provider "${providerName}"?`)) return; + + try { + const response = await fetch(`${API_BASE}/dns-providers/${providerId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to delete DNS provider'); + + showAlert('DNS provider deleted successfully', 'success'); + loadDnsProviders(); + } catch (error) { + showAlert('Error deleting DNS provider: ' + error.message, 'error'); + } + } + + // Show create certificate modal + function showCreateCertificateModal() { + const modalContent = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalContent); + + document.getElementById('createCertificateForm').addEventListener('submit', createCertificate); + loadDnsProvidersForSelect(); + } + + function closeCreateCertificateModal() { + const modal = document.querySelector('.modal-overlay'); + if (modal) { + modal.remove(); + } + } + + function toggleCertificateFields() { + const certType = document.getElementById('certType').value; + const letsencryptFields = document.getElementById('letsencryptFields'); + const importedFields = document.getElementById('importedFields'); + + letsencryptFields.style.display = certType === 'letsencrypt' ? 'block' : 'none'; + importedFields.style.display = certType === 'imported' ? 'block' : 'none'; + } + + async function loadDnsProvidersForSelect() { + try { + const response = await fetch(`${API_BASE}/dns-providers/cloudflare/active`); + const providers = await response.json(); + + const select = document.getElementById('dnsProvider'); + select.innerHTML = providers.length > 0 + ? providers.map(p => ``).join('') + : ''; + } catch (error) { + const select = document.getElementById('dnsProvider'); + select.innerHTML = ''; + } + } + + async function createCertificate(event) { + event.preventDefault(); + + const name = document.getElementById('certName').value.trim(); + const domain = document.getElementById('certDomain').value.trim(); + const cert_type = document.getElementById('certType').value; + const auto_renew = document.getElementById('autoRenew').checked; + + if (!name || !domain || !cert_type) { + showAlert('Name, domain, and certificate type are required', 'error'); + return; + } + + const certData = { name, domain, cert_type, auto_renew, certificate_pem: '', private_key: '' }; + + if (cert_type === 'letsencrypt') { + const dns_provider_id = document.getElementById('dnsProvider').value; + const acme_email = document.getElementById('acmeEmail').value.trim(); + + if (!dns_provider_id || !acme_email) { + showAlert('DNS provider and ACME email are required for Let\'s Encrypt certificates', 'error'); + return; + } + + certData.dns_provider_id = dns_provider_id; + certData.acme_email = acme_email; + } else if (cert_type === 'imported') { + const certificate_pem = document.getElementById('certPem').value.trim(); + const private_key = document.getElementById('keyPem').value.trim(); + + if (!certificate_pem || !private_key) { + showAlert('Certificate and private key PEM are required for imported certificates', 'error'); + return; + } + + certData.certificate_pem = certificate_pem; + certData.private_key = private_key; + } + + try { + showAlert('Creating certificate...', 'success'); + + const response = await fetch(`${API_BASE}/certificates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(certData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create certificate'); + } + + showAlert('Certificate created successfully', 'success'); + closeCreateCertificateModal(); + loadCertificates(); + } catch (error) { + showAlert('Error creating certificate: ' + error.message, 'error'); + } + } + + // Tasks + async function loadTasks() { + try { + const response = await fetch(`${API_BASE}/tasks`); + const data = await response.json(); + + // Update summary cards + document.getElementById('totalTasks').textContent = data.summary.total_tasks; + document.getElementById('runningTasks').textContent = data.summary.running_tasks; + document.getElementById('successTasks').textContent = data.summary.successful_tasks; + document.getElementById('errorTasks').textContent = data.summary.failed_tasks; + + if (Object.keys(data.tasks).length === 0) { + document.getElementById('tasksTable').innerHTML = '

No tasks found

No scheduled tasks are configured

'; + return; + } + + const tasksHtml = Object.entries(data.tasks).map(([taskId, task]) => { + const statusClass = task.status.toLowerCase(); + const lastRun = task.last_run ? new Date(task.last_run).toLocaleString() : 'Never'; + const nextRun = task.next_run ? new Date(task.next_run).toLocaleString() : 'Not scheduled'; + const duration = task.last_duration_ms ? `${task.last_duration_ms}ms` : '-'; + const successRate = task.total_runs > 0 ? Math.round((task.success_count / task.total_runs) * 100) : 0; + + return ` +
+
+
+

${escapeHtml(task.name)}

+

${escapeHtml(task.description)}

+
📅 ${escapeHtml(task.schedule)}
+
+
+ ${task.status} +
+ + +
+
+
+
+
+ + ${lastRun} +
+
+ + ${nextRun} +
+
+ + ${task.total_runs} +
+
+ + ${successRate}% (${task.success_count}/${task.total_runs}) +
+
+ + ${duration} +
+ ${task.last_error ? ` +
+ + ${escapeHtml(task.last_error)} +
+ ` : ''} +
+
+ `; + }).join(''); + + document.getElementById('tasksTable').innerHTML = ` +
+ ${tasksHtml} +
+ `; + + } catch (error) { + document.getElementById('tasksTable').innerHTML = '

Error loading tasks

' + error.message + '

'; + } + } + + async function refreshTasks() { + loadTasks(); + } + + async function triggerTask(taskId) { + try { + const response = await fetch(`${API_BASE}/tasks/${taskId}/trigger`, { + method: 'POST' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to trigger task'); + } + + const result = await response.json(); + showAlert(result.message, 'success'); + + // Refresh tasks after a short delay to show updated status + setTimeout(() => { + loadTasks(); + }, 1000); + + } catch (error) { + showAlert('Error triggering task: ' + error.message, 'error'); + } + } + // Initialize loadPageData('dashboard');