commit e5aacc9b414c6dee921432ddf1f04284372e2c03 Author: Ultradesu Date: Mon Jun 29 15:50:25 2026 +0300 Init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a75b18b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/.git +**/target +**/.claude +**/.agents +**/.codex +**/*.sqlite3 +**/NUL +**/nul diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..13a5242 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,51 @@ +name: Build and Publish + +on: + push: + tags: + - 'v*.*.*' + +env: + IMAGE_NAME: ultradesu/amnezia-fellow + +jobs: + build_docker: + name: Build and Publish Docker Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + run: | + VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2) + echo "cargo_version=${VERSION}" >> $GITHUB_OUTPUT + + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "docker_tags=${IMAGE_NAME}:${TAG_NAME},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == refs/heads/* ]]; then + BRANCH=${GITHUB_REF#refs/heads/} + echo "docker_tags=${IMAGE_NAME}:${BRANCH},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + else + echo "docker_tags=${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.docker_tags }} + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6959cf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sqlite3 +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2f2d35c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4544 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aide" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6966317188cdfe54c58c0900a195d021294afb3ece9b7073d09e4018dbb1e3a2" +dependencies = [ + "cfg-if", + "indexmap 2.14.0", + "schemars 0.9.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "amnezia-fellow" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cot", + "curve25519-dalek", + "getrandom 0.3.4", + "k8s-openapi", + "kube", + "miniz_oxide", + "openidconnect", + "qrcode", + "reqwest", + "schemars 0.9.0", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "askama" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" +dependencies = [ + "askama_parser", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" +dependencies = [ + "rustc-hash", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codemap" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86599f83c4c655eec5d93c17f0ae42f37435e5940cf99c06145813309f059f07" +dependencies = [ + "ahash", + "aide", + "askama", + "async-trait", + "axum", + "blake3", + "bytes", + "chrono", + "chrono-tz", + "clap", + "cot_core", + "cot_macros", + "derive_builder", + "derive_more", + "email_address", + "form_urlencoded", + "futures-core", + "futures-util", + "grass", + "hex", + "http", + "http-body-util", + "humantime", + "indexmap 2.14.0", + "mime", + "mime_guess", + "multer", + "password-auth", + "pin-project-lite", + "schemars 0.9.0", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "subtle", + "swagger-ui-redist", + "thiserror 2.0.18", + "time", + "tokio", + "toml", + "tower", + "tower-sessions", + "tracing", + "url", +] + +[[package]] +name = "cot_codegen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb54edb7e3f83eacaf6d8e76da12448ba5d34e481193e59aa89820e034d3221" +dependencies = [ + "darling 0.23.0", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cot_core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439acd4526d5ca44174a30ba68842cd938751993cfbdc1451d2225d5ffc2a8c1" +dependencies = [ + "askama", + "axum", + "backtrace", + "bytes", + "cot_macros", + "derive_more", + "form_urlencoded", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.0", + "schemars 0.9.0", + "serde", + "serde_html_form", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "thiserror 2.0.18", + "tower", + "tower-sessions", +] + +[[package]] +name = "cot_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250406bc5d4d20de14064500759e91cf5a2bcabaca719688cd83f2a78a9735fa" +dependencies = [ + "askama_derive", + "cot_codegen", + "darling 0.23.0", + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "grass" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a68216437ef68f0738e48d6c7bb9e6e6a92237e001b03d838314b068f33c94" +dependencies = [ + "getrandom 0.2.17", + "grass_compiler", +] + +[[package]] +name = "grass_compiler" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" +dependencies = [ + "codemap", + "indexmap 2.14.0", + "lasso", + "once_cell", + "phf 0.11.3", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633a7320c4bb672863a3782e89b9094ad70285e097ff6832cddd0ec615beadfa" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "k8s-openapi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b326f5219dd55872a72c1b6ddd1b830b8334996c667449c29391d657d78d5e" +dependencies = [ + "base64 0.22.1", + "jiff", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc5a6a69da2975ed9925d56b5dcfc9cc739b66f37add06785b7c9f6d1e88741" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcaf2d1f1a91e1805d4cd82e8333c022767ae8ffd65909bbef6802733a7dd40" +dependencies = [ + "base64 0.22.1", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f126d2db7a8b532ec1d839ece2a71e2485dc3bbca6cc3c3f929becaa810e719e" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "lasso" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.17", + "password-hash", + "rand_core 0.6.4", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "indexmap 2.14.0", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "chrono", + "inherent", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "chrono", + "sea-query", + "sqlx", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_html_form" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0946d52b4b7e28823148aebbeceb901012c595ad737920d504fa8634bb099e6f" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swagger-ui-redist" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfaaf9b731f41f15b1a7d01419e6ff9ff59eef7bc6c04eae04911e3dbe5e17a" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "base64 0.22.1", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1d73919 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "amnezia-fellow" +version = "0.1.1" +edition = "2024" +description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync" + +[dependencies] +cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json", "openapi", "swagger-ui"] } +schemars = { version = "0.9", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +openidconnect = "4.0" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["sync"] } +base64 = "0.22" +miniz_oxide = "0.8" +qrcode = "0.14" +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +curve25519-dalek = "4.1" +getrandom = "0.3" +kube = { version = "3.1.0", default-features = false, features = ["client", "rustls-tls", "ring"] } +k8s-openapi = { version = "0.27.1", features = ["v1_32"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58fcf43 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM rust:1-slim AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock* ./ +COPY build.rs ./build.rs +COPY src ./src +COPY templates ./templates + +RUN cargo build --release + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /data +COPY --from=builder /app/target/release/amnezia-fellow /usr/local/bin/amnezia-fellow + +EXPOSE 8000 +CMD ["amnezia-fellow", "--listen", "0.0.0.0:8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..91d6cf2 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# amnezia-fellow + +Amnezia VPN client manager written in Rust on top of the public [`cot`](https://cot.rs) framework. + +The app uses SQLite as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical. + +## Quick Start + +```bash +export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc +cargo run -- --listen 127.0.0.1:8000 +``` + +Open `http://localhost:8000/admin/setup` to create the first local admin account. + +## Docker + +The image is built by GitHub Actions and published to Docker Hub as +`ultradesu/amnezia-fellow`. + +Required repository secrets: + +- `DOCKERHUB_USERNAME` +- `DOCKERHUB_TOKEN` + +Publishing runs for version tags matching `v*.*.*`. A tag like `v0.1.0` pushes: + +- `ultradesu/amnezia-fellow:v0.1.0` +- `ultradesu/amnezia-fellow:0.1.0` +- `ultradesu/amnezia-fellow:latest` + +Local image build: + +```bash +docker build -t amnezia-fellow . +docker run --rm -p 8000:8000 -v "$PWD/data:/data" amnezia-fellow +``` + +## Roles + +There are two roles: + +- `admin`: full access, sees all client configs. +- `client`: sees and manages only their own configs. + +OIDC provisioning is deny-by-default: + +- users in `AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS` become `admin`; +- users in `AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS` become `client`; +- users outside both group lists cannot sign in. + +The OIDC groups claim is expected to be `groups`. + +## VPN Data Model + +SQLite stores all client data needed to restore configs: + +- owner user id +- display name +- assigned IPv4 address +- public key +- private key +- enabled flag +- created/updated timestamps + +The Kubernetes Secret is derived from the database. Active clients are rendered into one configured Secret key, `peers.conf` by default. + +## Kubernetes Sync + +The app is intended to run inside Kubernetes with a ServiceAccount and RBAC that can read and update Secrets and list Pods in the Amnezia namespace. + +It reads: + +- server public key from `AMNEZIA_FELLOW_K8S_SERVER_SECRET`, key `server-public-key`; +- node endpoints from `AMNEZIA_FELLOW_K8S_ENDPOINTS_SECRET`; Secret data keys are used as Amnezia server names in generated `vpn://` links; +- AmneziaWG pods selected by `app=amneziawg` for config rollout status; +- writes rendered client peers to `AMNEZIA_FELLOW_K8S_CLIENTS_SECRET`. + +It does not modify the server Secret. Argo/ExternalSecrets can keep managing `amneziawg-server`, while amnezia-fellow owns only the client Secret content. + +## UI + +The main user interface is `/configs`. It is an Alpine.js reactive page backed by JSON API endpoints: + +- clients and admins use the same page; +- the backend filters data by role; +- admins get an extra manual Secret sync action. +- clients and admins see AmneziaWG pod rollout status and uptime. + +## Environment Variables + +All settings use `AMNEZIA_FELLOW_` prefix. Priority is: + +`environment variable > database override > compiled default` + +| Variable | Description | Default | +| --- | --- | --- | +| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite connection URL | `sqlite://amnezia-fellow.sqlite3?mode=rwc` | +| `AMNEZIA_FELLOW_LOG_LEVEL` | Tracing filter | `info` | +| `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` | +| `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` | +| `AMNEZIA_FELLOW_OIDC_ISSUER` | OIDC issuer URL | empty | +| `AMNEZIA_FELLOW_OIDC_CLIENT_ID` | OIDC client ID | empty | +| `AMNEZIA_FELLOW_OIDC_CLIENT_SECRET` | OIDC client secret | empty | +| `AMNEZIA_FELLOW_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` | +| `AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS` | Comma-separated admin groups | empty | +| `AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS` | Comma-separated client groups | empty | +| `AMNEZIA_FELLOW_K8S_NAMESPACE` | Amnezia namespace | `amnezia` | +| `AMNEZIA_FELLOW_K8S_CLIENTS_SECRET` | Client peers Secret | `amneziawg-clients` | +| `AMNEZIA_FELLOW_K8S_CLIENTS_SECRET_KEY` | Secret data key for rendered peers | `peers.conf` | +| `AMNEZIA_FELLOW_K8S_SERVER_SECRET` | Server config Secret | `amneziawg-server` | +| `AMNEZIA_FELLOW_K8S_ENDPOINTS_SECRET` | Node endpoints Secret | `amneziawg-endpoints` | +| `AMNEZIA_FELLOW_VPN_CLIENT_CIDR` | Client address pool | `10.8.0.0/16` | +| `AMNEZIA_FELLOW_VPN_DNS` | DNS servers in generated configs | `1.1.1.1, 8.8.8.8` | +| `AMNEZIA_FELLOW_VPN_MTU` | MTU in generated configs | `1376` | +| `AMNEZIA_FELLOW_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` | + +## API + +The JSON API is session-authenticated: + +- `GET /api/me` +- `GET /api/vpn-clients` +- `GET /api/vpn-status` +- `POST /api/vpn-clients` +- `POST /api/vpn-clients/{id}/enabled` +- `DELETE /api/vpn-clients/{id}` +- `GET /api/vpn-clients/{id}/config` returns `servers[]` with one raw AWG config and one Amnezia `vpn://` import link per registered endpoint +- `POST /api/vpn-clients/sync` + +## Development + +```bash +cargo fmt +cargo check +``` + +The project depends on public crates only; `cot = "0.6.0"` is pulled from crates.io. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..46df776 --- /dev/null +++ b/build.rs @@ -0,0 +1,17 @@ +fn main() { + println!( + "cargo::rustc-env=AMNEZIA_FELLOW_TARGET={}", + std::env::var("TARGET").unwrap() + ); + + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let output = std::process::Command::new(rustc) + .arg("--version") + .output() + .expect("failed to run rustc --version"); + let version = String::from_utf8_lossy(&output.stdout); + println!( + "cargo::rustc-env=AMNEZIA_FELLOW_RUSTC_VERSION={}", + version.trim() + ); +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..6c36575 --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,317 @@ +pub mod views; + +use cot::db::Database; +use cot::db::migrations::SyncDynMigration; +use cot::json::Json; +use cot::request::extractors::{Path, RequestForm}; +use cot::response::IntoResponse; +use cot::router::method::{get, post}; +use cot::router::{Route, Router}; +use cot::session::Session; +use cot::{App, Body}; +use serde::Deserialize; + +use crate::auth::{self, AuthenticatedUser, Role}; +use crate::i18n::I18n; +use crate::user::User; +use views::{AdminSettingsRequest, AdminUserRequest, AdminVpnServersRequest, SetupForm}; + +/// Build-time metadata baked in by `build.rs` and Cargo env vars. +#[derive(Debug)] +pub struct BuildInfo { + pub pkg_name: &'static str, + pub pkg_version: &'static str, + pub profile: &'static str, + pub target: &'static str, + pub rustc_version: &'static str, +} + +pub static BUILD_INFO: BuildInfo = BuildInfo { + pkg_name: env!("CARGO_PKG_NAME"), + pkg_version: env!("CARGO_PKG_VERSION"), + profile: if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + target: env!("AMNEZIA_FELLOW_TARGET"), + rustc_version: env!("AMNEZIA_FELLOW_RUSTC_VERSION"), +}; + +pub struct AdminApp; + +impl AdminApp { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +struct PathId { + id: i64, +} + +fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { + let body = serde_json::json!({ "error": message }); + cot::http::Response::builder() + .status(status) + .header(cot::http::header::CONTENT_TYPE, "application/json") + .body(Body::fixed(body.to_string())) + .expect("valid response") +} + +async fn require_admin_json( + session: &Session, + db: &Database, +) -> Result { + let Some(user) = auth::get_session_user(session, db).await else { + return Err(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + if user.role != Role::Admin { + return Err(json_error( + cot::http::StatusCode::FORBIDDEN, + "admin role required", + )); + } + Ok(user) +} + +impl App for AdminApp { + fn name(&self) -> &'static str { + "admin" + } + + fn router(&self) -> Router { + Router::with_urls([ + // -- Setup (first-run, no auth required) -------------------------- + Route::with_handler_and_name( + "/setup", + get(|i18n: I18n, db: Database| async move { + let count = User::count_all(&db).await.unwrap_or(1); + if count > 0 { + return Ok(auth::redirect("/admin/")); + } + views::setup_page(i18n, String::new()) + .await? + .into_response() + }) + .post( + |i18n: I18n, db: Database, session: Session, + form: RequestForm| async move { + let count = User::count_all(&db).await.unwrap_or(1); + if count > 0 { + return Ok(auth::redirect("/admin/")); + } + views::setup_submit(i18n, &db, &session, form).await + }, + ), + "admin_setup", + ), + // -- Alpine admin shell ------------------------------------------- + Route::with_handler_and_name( + "/", + |session: Session, db: Database, i18n: I18n| async move { + let count = User::count_all(&db).await.unwrap_or(0); + if count == 0 { + return Ok(auth::redirect("/admin/setup")); + } + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_app(admin, i18n, "dashboard") + .await? + .into_response() + }, + "admin_index", + ), + Route::with_handler_and_name( + "/users", + |session: Session, db: Database, i18n: I18n| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_app(admin, i18n, "users") + .await? + .into_response() + }, + "admin_users", + ), + Route::with_handler_and_name( + "/settings", + |session: Session, db: Database, i18n: I18n| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_app(admin, i18n, "settings") + .await? + .into_response() + }, + "admin_settings", + ), + Route::with_handler_and_name( + "/servers", + |session: Session, db: Database, i18n: I18n| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_app(admin, i18n, "servers") + .await? + .into_response() + }, + "admin_servers", + ), + Route::with_handler_and_name( + "/debug", + |session: Session, db: Database, i18n: I18n| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_app(admin, i18n, "debug") + .await? + .into_response() + }, + "admin_debug", + ), + Route::with_handler_and_name( + "/users/new", + get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/users")) }), + "admin_users_new", + ), + Route::with_handler_and_name( + "/users/{id}/edit", + get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/users")) }), + "admin_users_edit", + ), + // -- Alpine JSON API ---------------------------------------------- + Route::with_handler_and_name( + "/api/summary", + |session: Session, db: Database| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_summary_api(admin, &db).await + }, + "admin_api_summary", + ), + Route::with_handler_and_name( + "/api/debug", + |session: Session, db: Database, i18n: I18n| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_debug_api(admin, i18n, &db).await + }, + "admin_api_debug", + ), + Route::with_handler_and_name( + "/api/settings", + get(|session: Session, db: Database| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_settings_api(admin, &db).await + }) + .post( + |session: Session, db: Database, Json(request): Json| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_settings_save_api(admin, &db, Json(request)).await + }, + ), + "admin_api_settings", + ), + Route::with_handler_and_name( + "/api/users", + get(|session: Session, db: Database| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_users_api(admin, &db).await + }) + .post( + |session: Session, db: Database, Json(request): Json| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_user_create_api(admin, &db, Json(request)).await + }, + ), + "admin_api_users", + ), + Route::with_handler_and_name( + "/api/users/{id}", + post( + |session: Session, db: Database, path: Path, + Json(request): Json| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_user_update_api(admin, &db, path.0.id, Json(request)).await + }, + ), + "admin_api_user_update", + ), + Route::with_handler_and_name( + "/api/users/{id}/delete", + post( + |session: Session, db: Database, path: Path| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_user_delete_api(admin, &db, path.0.id).await + }, + ), + "admin_api_user_delete", + ), + Route::with_handler_and_name( + "/api/servers", + get(|session: Session, db: Database| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_vpn_servers_api(admin, &db).await + }) + .post( + |session: Session, db: Database, Json(request): Json| async move { + let admin = match require_admin_json(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_vpn_servers_save_api(admin, &db, Json(request)).await + }, + ), + "admin_api_servers", + ), + ]) + } + + fn migrations(&self) -> Vec> { + let mut all = + cot::db::migrations::wrap_migrations(crate::config::db_migrations::MIGRATIONS); + all.extend(cot::db::migrations::wrap_migrations( + crate::user::db_migrations::MIGRATIONS, + )); + all.extend(cot::db::migrations::wrap_migrations( + crate::vpn::db_migrations::MIGRATIONS, + )); + all + } +} diff --git a/src/admin/views.rs b/src/admin/views.rs new file mode 100644 index 0000000..8376671 --- /dev/null +++ b/src/admin/views.rs @@ -0,0 +1,842 @@ +use std::collections::{BTreeMap, HashSet}; + +use cot::db::{Database, Model}; +use cot::form::{Form, FormResult}; +use cot::html::Html; +use cot::json::Json; +use cot::request::extractors::RequestForm; +use cot::response::IntoResponse; +use cot::session::Session; +use cot::{Body, Template}; +use serde::{Deserialize, Serialize}; + +use super::BUILD_INFO; +use crate::auth::{self, AuthenticatedUser}; +use crate::config::{AppConfig, ConfigEntry, ConfigSources}; +use crate::i18n::{I18n, Translations}; +use crate::user::User; +use crate::vpn; + +/// A config entry for display in the unified debug table. +#[derive(Debug, Serialize)] +pub struct ConfigDisplayEntry { + pub key: String, + pub env_var: String, + pub value: String, + pub default_value: String, + pub source: &'static str, +} + +fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { + let body = serde_json::json!({ "error": message }); + cot::http::Response::builder() + .status(status) + .header(cot::http::header::CONTENT_TYPE, "application/json") + .body(Body::fixed(body.to_string())) + .expect("valid response") +} + +/// Secret field names that should be redacted in the debug view. +const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"]; + +fn is_secret(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + SECRET_FIELDS.iter().any(|s| lower.contains(s)) + || lower.contains("secret") + || lower.contains("token") +} + +fn redact(value: &str) -> String { + if value.is_empty() { + String::new() + } else { + "********".into() + } +} + +fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec { + let defaults = AppConfig::default(); + + macro_rules! entry { + ($field:ident, $value:expr, $default:expr) => {{ + let raw = $value; + let default_raw = $default; + let secret = is_secret(stringify!($field)); + let display = if secret { redact(&raw) } else { raw }; + let default_display = if secret { + redact(&default_raw) + } else { + default_raw + }; + ConfigDisplayEntry { + key: stringify!($field).into(), + env_var: format!("AMNEZIA_FELLOW_{}", stringify!($field).to_ascii_uppercase()), + value: display, + default_value: default_display, + source: sources.$field.code(), + } + }}; + } + + vec![ + entry!( + database_url, + config.database_url.clone(), + defaults.database_url.clone() + ), + entry!( + oidc_issuer, + config.oidc_issuer.clone(), + defaults.oidc_issuer.clone() + ), + entry!( + oidc_client_id, + config.oidc_client_id.clone(), + defaults.oidc_client_id.clone() + ), + entry!( + oidc_client_secret, + config.oidc_client_secret.clone(), + defaults.oidc_client_secret.clone() + ), + entry!( + log_level, + config.log_level.clone(), + defaults.log_level.clone() + ), + entry!( + auth_password_enabled, + config.auth_password_enabled.to_string(), + defaults.auth_password_enabled.to_string() + ), + entry!( + auth_sso_enabled, + config.auth_sso_enabled.to_string(), + defaults.auth_sso_enabled.to_string() + ), + entry!( + oidc_button_text, + config.oidc_button_text.clone(), + defaults.oidc_button_text.clone() + ), + entry!( + oidc_admin_groups, + config.oidc_admin_groups.clone(), + defaults.oidc_admin_groups.clone() + ), + entry!( + oidc_client_groups, + config.oidc_client_groups.clone(), + defaults.oidc_client_groups.clone() + ), + entry!( + k8s_namespace, + config.k8s_namespace.clone(), + defaults.k8s_namespace.clone() + ), + entry!( + k8s_clients_secret, + config.k8s_clients_secret.clone(), + defaults.k8s_clients_secret.clone() + ), + entry!( + k8s_clients_secret_key, + config.k8s_clients_secret_key.clone(), + defaults.k8s_clients_secret_key.clone() + ), + entry!( + k8s_server_secret, + config.k8s_server_secret.clone(), + defaults.k8s_server_secret.clone() + ), + entry!( + k8s_endpoints_secret, + config.k8s_endpoints_secret.clone(), + defaults.k8s_endpoints_secret.clone() + ), + entry!( + vpn_disabled_endpoints, + config.vpn_disabled_endpoints.clone(), + defaults.vpn_disabled_endpoints.clone() + ), + entry!( + vpn_endpoint_name_overrides, + config.vpn_endpoint_name_overrides.clone(), + defaults.vpn_endpoint_name_overrides.clone() + ), + entry!( + vpn_client_cidr, + config.vpn_client_cidr.clone(), + defaults.vpn_client_cidr.clone() + ), + entry!(vpn_dns, config.vpn_dns.clone(), defaults.vpn_dns.clone()), + entry!( + vpn_mtu, + config.vpn_mtu.to_string(), + defaults.vpn_mtu.to_string() + ), + entry!( + swagger_enabled, + config.swagger_enabled.to_string(), + defaults.swagger_enabled.to_string() + ), + ] +} + +#[derive(Debug, Template)] +#[template(path = "admin/app.html")] +struct AdminAppTemplate { + t: &'static Translations, + user_name: String, + user_role: String, + initial_view: String, + app_version: &'static str, +} + +pub async fn admin_app( + admin: AuthenticatedUser, + i18n: I18n, + initial_view: &str, +) -> cot::Result { + let template = AdminAppTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + initial_view: initial_view.to_owned(), + app_version: env!("CARGO_PKG_VERSION"), + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Serialize)] +pub struct AdminSummaryResponse { + users_count: u64, + admin_users_count: usize, + client_users_count: usize, + active_users_count: usize, + build: BuildInfoView, +} + +#[derive(Debug, Serialize)] +pub struct BuildInfoView { + pkg_name: &'static str, + pkg_version: &'static str, + profile: &'static str, + target: &'static str, + rustc_version: &'static str, +} + +impl From<&'static super::BuildInfo> for BuildInfoView { + fn from(build: &'static super::BuildInfo) -> Self { + Self { + pkg_name: build.pkg_name, + pkg_version: build.pkg_version, + profile: build.profile, + target: build.target, + rustc_version: build.rustc_version, + } + } +} + +#[derive(Debug, Serialize)] +pub struct AdminDebugResponse { + build: BuildInfoView, + db_status: String, + config_entries: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminSettingsResponse { + fields: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminSettingField { + key: String, + env_var: String, + value: String, + default_value: String, + source: &'static str, + secret: bool, + kind: &'static str, + section: &'static str, +} + +#[derive(Debug, Deserialize)] +pub struct AdminSettingsRequest { + auth_password_enabled: bool, + auth_sso_enabled: bool, + oidc_button_text: String, + oidc_issuer: String, + oidc_client_id: String, + oidc_client_secret: String, + oidc_admin_groups: String, + oidc_client_groups: String, + k8s_namespace: String, + k8s_clients_secret: String, + k8s_clients_secret_key: String, + k8s_server_secret: String, + k8s_endpoints_secret: String, + vpn_disabled_endpoints: String, + vpn_endpoint_name_overrides: String, + vpn_client_cidr: String, + vpn_dns: String, + vpn_mtu: u16, + swagger_enabled: bool, +} + +#[derive(Debug, Serialize)] +pub struct AdminUserView { + id: i64, + username: String, + email: String, + display_name: String, + role: String, + active: bool, +} + +impl From for AdminUserView { + fn from(user: User) -> Self { + Self { + id: user.id_val(), + username: user.username_str().to_owned(), + email: user.email_str(), + display_name: user.display_name_str(), + role: user.role_str().to_owned(), + active: user.is_active(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct AdminUsersResponse { + users: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminUserResponse { + user: AdminUserView, +} + +#[derive(Debug, Deserialize)] +pub struct AdminUserRequest { + username: String, + email: String, + display_name: String, + password: String, + role: String, +} + +#[derive(Debug, Serialize)] +pub struct AdminDeleteResponse { + deleted: bool, +} + +#[derive(Debug, Serialize)] +pub struct AdminVpnServersResponse { + servers: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminVpnServerView { + name: String, + display_name: String, + endpoint: String, + enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub struct AdminVpnServersRequest { + servers: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AdminVpnServerUpdate { + name: String, + display_name: String, + enabled: bool, +} + +pub async fn admin_summary_api( + _admin: AuthenticatedUser, + db: &Database, +) -> cot::Result { + let users = User::list_all(db) + .await + .map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?; + let users_count = users.len() as u64; + let admin_users_count = users + .iter() + .filter(|user| user.role_str() == "admin") + .count(); + let client_users_count = users + .iter() + .filter(|user| user.role_str() == "client") + .count(); + let active_users_count = users.iter().filter(|user| user.is_active()).count(); + + Json(AdminSummaryResponse { + users_count, + admin_users_count, + client_users_count, + active_users_count, + build: (&BUILD_INFO).into(), + }) + .into_response() +} + +pub async fn admin_debug_api( + _admin: AuthenticatedUser, + i18n: I18n, + db: &Database, +) -> cot::Result { + let (config, sources) = AppConfig::load_with_db(db).await; + let db_status = match db.raw("SELECT 1").await { + Ok(_) => i18n.t.debug_db_connected.to_owned(), + Err(e) => format!("{}: {e}", i18n.t.debug_db_error), + }; + + Json(AdminDebugResponse { + build: (&BUILD_INFO).into(), + db_status, + config_entries: config_display_entries(&config, &sources), + }) + .into_response() +} + +pub async fn admin_settings_api( + _admin: AuthenticatedUser, + db: &Database, +) -> cot::Result { + let (config, sources) = AppConfig::load_with_db(db).await; + Json(AdminSettingsResponse { + fields: settings_fields(&config, &sources), + }) + .into_response() +} + +pub async fn admin_settings_save_api( + _admin: AuthenticatedUser, + db: &Database, + Json(request): Json, +) -> cot::Result { + save_settings_request(db, &request).await?; + admin_settings_api(_admin, db).await +} + +pub async fn admin_users_api( + _admin: AuthenticatedUser, + db: &Database, +) -> cot::Result { + let users = User::list_all(db) + .await + .map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))? + .into_iter() + .map(AdminUserView::from) + .collect(); + Json(AdminUsersResponse { users }).into_response() +} + +pub async fn admin_vpn_servers_api( + _admin: AuthenticatedUser, + db: &Database, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(db).await; + let disabled = vpn::disabled_endpoint_names(&config); + let runtime = vpn::read_runtime_from_kubernetes(&config) + .await + .map_err(|e| cot::Error::internal(format!("failed to read VPN endpoints: {e}")))?; + let servers = runtime + .endpoints + .into_iter() + .map(|endpoint| AdminVpnServerView { + enabled: !disabled.contains(&endpoint.name), + display_name: endpoint.display_name, + name: endpoint.name, + endpoint: endpoint.endpoint, + }) + .collect(); + + Json(AdminVpnServersResponse { servers }).into_response() +} + +pub async fn admin_vpn_servers_save_api( + admin: AuthenticatedUser, + db: &Database, + Json(request): Json, +) -> cot::Result { + let mut name_overrides = BTreeMap::new(); + let disabled = request + .servers + .into_iter() + .filter_map(|server| { + let name = server.name.trim().to_owned(); + if name.is_empty() { + return None; + } + + let display_name = server.display_name.trim(); + if !display_name.is_empty() && display_name != name { + name_overrides.insert(name.clone(), display_name.to_owned()); + } + + (!server.enabled).then_some(name) + }) + .collect::>(); + let mut disabled = disabled.into_iter().collect::>(); + disabled.sort(); + let name_overrides = serde_json::to_string(&name_overrides) + .map_err(|e| cot::Error::internal(format!("failed to serialize VPN server names: {e}")))?; + + let mut disabled_entry = + ConfigEntry::new("vpn_disabled_endpoints".to_owned(), disabled.join(",")); + disabled_entry + .save(db) + .await + .map_err(|e| cot::Error::internal(format!("failed to save VPN server settings: {e}")))?; + let mut names_entry = + ConfigEntry::new("vpn_endpoint_name_overrides".to_owned(), name_overrides); + names_entry + .save(db) + .await + .map_err(|e| cot::Error::internal(format!("failed to save VPN server names: {e}")))?; + admin_vpn_servers_api(admin, db).await +} + +pub async fn admin_user_create_api( + _admin: AuthenticatedUser, + db: &Database, + Json(request): Json, +) -> cot::Result { + if request.password.trim().is_empty() { + return Ok(json_error( + cot::http::StatusCode::BAD_REQUEST, + "password is required", + )); + } + validate_role(&request.role)?; + let email = optional_str(&request.email); + let display_name = optional_str(&request.display_name); + let user = User::create( + db, + request.username.trim(), + email, + display_name, + &request.password, + &request.role, + ) + .await + .map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?; + Json(AdminUserResponse { + user: AdminUserView::from(user), + }) + .into_response() +} + +pub async fn admin_user_update_api( + _admin: AuthenticatedUser, + db: &Database, + user_id: i64, + Json(request): Json, +) -> cot::Result { + validate_role(&request.role)?; + let Some(mut user) = User::get_by_id(db, user_id) + .await + .map_err(|e| cot::Error::internal(format!("failed to load user: {e}")))? + else { + return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); + }; + + let email = optional_str(&request.email); + let display_name = optional_str(&request.display_name); + let new_password = optional_str(&request.password); + user.update_fields( + db, + request.username.trim(), + email, + display_name, + new_password, + &request.role, + ) + .await + .map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?; + Json(AdminUserResponse { + user: AdminUserView::from(user), + }) + .into_response() +} + +pub async fn admin_user_delete_api( + _admin: AuthenticatedUser, + db: &Database, + user_id: i64, +) -> cot::Result { + User::delete_by_id(db, user_id) + .await + .map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?; + Json(AdminDeleteResponse { deleted: true }).into_response() +} + +fn optional_str(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn validate_role(role: &str) -> cot::Result<()> { + if matches!(role, "admin" | "client") { + Ok(()) + } else { + Err(cot::Error::internal(format!("invalid role: {role}"))) + } +} + +fn settings_fields(config: &AppConfig, sources: &ConfigSources) -> Vec { + let defaults = AppConfig::default(); + + macro_rules! field { + ($section:expr, $kind:expr, $field:ident, $value:expr, $default:expr) => {{ + let raw = $value; + let default_raw = $default; + let secret = is_secret(stringify!($field)); + AdminSettingField { + key: stringify!($field).into(), + env_var: format!("AMNEZIA_FELLOW_{}", stringify!($field).to_ascii_uppercase()), + value: raw, + default_value: default_raw, + source: sources.$field.code(), + secret, + kind: $kind, + section: $section, + } + }}; + } + + vec![ + field!( + "auth", + "bool", + auth_password_enabled, + config.auth_password_enabled.to_string(), + defaults.auth_password_enabled.to_string() + ), + field!( + "auth", + "bool", + auth_sso_enabled, + config.auth_sso_enabled.to_string(), + defaults.auth_sso_enabled.to_string() + ), + field!( + "oidc", + "text", + oidc_button_text, + config.oidc_button_text.clone(), + defaults.oidc_button_text.clone() + ), + field!( + "oidc", + "text", + oidc_issuer, + config.oidc_issuer.clone(), + defaults.oidc_issuer.clone() + ), + field!( + "oidc", + "text", + oidc_client_id, + config.oidc_client_id.clone(), + defaults.oidc_client_id.clone() + ), + field!( + "oidc", + "password", + oidc_client_secret, + config.oidc_client_secret.clone(), + defaults.oidc_client_secret.clone() + ), + field!( + "oidc", + "text", + oidc_admin_groups, + config.oidc_admin_groups.clone(), + defaults.oidc_admin_groups.clone() + ), + field!( + "oidc", + "text", + oidc_client_groups, + config.oidc_client_groups.clone(), + defaults.oidc_client_groups.clone() + ), + field!( + "kubernetes", + "text", + k8s_namespace, + config.k8s_namespace.clone(), + defaults.k8s_namespace.clone() + ), + field!( + "kubernetes", + "text", + k8s_clients_secret, + config.k8s_clients_secret.clone(), + defaults.k8s_clients_secret.clone() + ), + field!( + "kubernetes", + "text", + k8s_clients_secret_key, + config.k8s_clients_secret_key.clone(), + defaults.k8s_clients_secret_key.clone() + ), + field!( + "kubernetes", + "text", + k8s_server_secret, + config.k8s_server_secret.clone(), + defaults.k8s_server_secret.clone() + ), + field!( + "kubernetes", + "text", + k8s_endpoints_secret, + config.k8s_endpoints_secret.clone(), + defaults.k8s_endpoints_secret.clone() + ), + field!( + "vpn", + "text", + vpn_disabled_endpoints, + config.vpn_disabled_endpoints.clone(), + defaults.vpn_disabled_endpoints.clone() + ), + field!( + "vpn", + "text", + vpn_endpoint_name_overrides, + config.vpn_endpoint_name_overrides.clone(), + defaults.vpn_endpoint_name_overrides.clone() + ), + field!( + "vpn", + "text", + vpn_client_cidr, + config.vpn_client_cidr.clone(), + defaults.vpn_client_cidr.clone() + ), + field!( + "vpn", + "text", + vpn_dns, + config.vpn_dns.clone(), + defaults.vpn_dns.clone() + ), + field!( + "vpn", + "number", + vpn_mtu, + config.vpn_mtu.to_string(), + defaults.vpn_mtu.to_string() + ), + field!( + "api", + "bool", + swagger_enabled, + config.swagger_enabled.to_string(), + defaults.swagger_enabled.to_string() + ), + ] +} + +async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> cot::Result<()> { + let vpn_mtu = data.vpn_mtu.to_string(); + let auth_password_enabled = data.auth_password_enabled.to_string(); + let auth_sso_enabled = data.auth_sso_enabled.to_string(); + let swagger_enabled = data.swagger_enabled.to_string(); + let fields: [(&str, &str); 19] = [ + ("auth_password_enabled", &auth_password_enabled), + ("auth_sso_enabled", &auth_sso_enabled), + ("oidc_button_text", &data.oidc_button_text), + ("oidc_issuer", &data.oidc_issuer), + ("oidc_client_id", &data.oidc_client_id), + ("oidc_client_secret", &data.oidc_client_secret), + ("oidc_admin_groups", &data.oidc_admin_groups), + ("oidc_client_groups", &data.oidc_client_groups), + ("k8s_namespace", &data.k8s_namespace), + ("k8s_clients_secret", &data.k8s_clients_secret), + ("k8s_clients_secret_key", &data.k8s_clients_secret_key), + ("k8s_server_secret", &data.k8s_server_secret), + ("k8s_endpoints_secret", &data.k8s_endpoints_secret), + ("vpn_disabled_endpoints", &data.vpn_disabled_endpoints), + ( + "vpn_endpoint_name_overrides", + &data.vpn_endpoint_name_overrides, + ), + ("vpn_client_cidr", &data.vpn_client_cidr), + ("vpn_dns", &data.vpn_dns), + ("vpn_mtu", &vpn_mtu), + ("swagger_enabled", &swagger_enabled), + ]; + for (key, value) in fields { + let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned()); + if let Err(e) = entry.save(db).await { + tracing::error!(key, error = %e, "failed to save config entry"); + return Err(e.into()); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// First-run setup page +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "admin/setup.html")] +struct SetupTemplate { + t: &'static Translations, + message: String, +} + +pub async fn setup_page(i18n: I18n, message: String) -> cot::Result { + let template = SetupTemplate { t: i18n.t, message }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +pub struct SetupForm { + username: String, + password: String, + confirm_password: String, +} + +pub async fn setup_submit( + i18n: I18n, + db: &Database, + session: &Session, + form: RequestForm, +) -> cot::Result { + let RequestForm(result) = form; + let data = match result { + FormResult::Ok(data) => data, + FormResult::ValidationError(_) => { + return setup_page(i18n, String::new()).await?.into_response(); + } + }; + + if data.password != data.confirm_password { + let msg = i18n.t.setup_mismatch.to_owned(); + return setup_page(i18n, msg).await?.into_response(); + } + + let user = User::create(db, &data.username, None, None, &data.password, "admin") + .await + .map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?; + + auth::login(session, user.id_val()).await?; + Ok(auth::redirect("/admin/")) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..e5acf32 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,467 @@ +use std::collections::HashMap; + +use cot::db::Database; +use cot::json::Json; +use cot::request::extractors::Path; +use cot::response::IntoResponse; +use cot::router::method::openapi::{api_delete, api_get, api_post}; +use cot::router::{Route, Router}; +use cot::session::Session; +use cot::{App, Body}; +use qrcode::QrCode; +use qrcode::render::svg; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::AppConfig; +use crate::user::User; +use crate::{auth, vpn}; + +// --------------------------------------------------------------------------- +// JSON error helper +// --------------------------------------------------------------------------- + +fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { + let body = serde_json::json!({ "error": message }); + cot::http::Response::builder() + .status(status) + .header(cot::http::header::CONTENT_TYPE, "application/json") + .body(Body::fixed(body.to_string())) + .expect("valid response") +} + +// --------------------------------------------------------------------------- +// GET /api/me +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, JsonSchema)] +struct MeResponse { + id: i64, + name: String, + role: String, +} + +async fn me_handler(session: Session, db: Database) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + + Json(MeResponse { + id: user.id, + name: user.name, + role: user.role.code().to_owned(), + }) + .into_response() +} + +// --------------------------------------------------------------------------- +// VPN client API +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, JsonSchema)] +struct VpnClientsResponse { + role: String, + clients: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateVpnClientRequest { + name: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct MutateVpnClientResponse { + client: vpn::VpnClientView, + sync: vpn::SecretSyncResult, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SetEnabledRequest { + enabled: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct DeleteVpnClientResponse { + sync: vpn::SecretSyncResult, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct ClientServerConfigResponse { + endpoint_id: String, + endpoint_name: String, + endpoint: String, + config: String, + vpn_url: String, + qr_payload: String, + qr_svg: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct ClientConfigResponse { + id: i64, + name: String, + servers: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ClientPath { + id: i64, +} + +async fn vpn_clients_handler( + session: Session, + db: Database, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + + let clients = vpn::VpnClient::list_visible(&db, &user) + .await + .map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?; + let owner_map = owner_view_map(&db, &clients).await?; + let clients = clients + .into_iter() + .map(|client| client_view_with_owner(client, &owner_map)) + .collect(); + + Json(VpnClientsResponse { + role: user.role.code().to_owned(), + clients, + }) + .into_response() +} + +async fn vpn_status_handler( + session: Session, + db: Database, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + } + }; + tracing::debug!(user_id = user.id, "VPN rollout status requested"); + + let (config, _) = AppConfig::load_with_db(&db).await; + let status = vpn::read_rollout_status_from_kubernetes(&config) + .await + .map_err(|e| cot::Error::internal(format!("failed to read VPN rollout status: {e}")))?; + + Json(status).into_response() +} + +async fn create_vpn_client_handler( + session: Session, + db: Database, + Json(request): Json, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + } + }; + + let (config, _) = AppConfig::load_with_db(&db).await; + let client = + vpn::VpnClient::create_for_owner(&db, user.id, &request.name, &config.vpn_client_cidr) + .await + .map_err(|e| cot::Error::internal(format!("failed to create client: {e}")))?; + let sync = vpn::sync_from_database(&db, &config) + .await + .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + + let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; + Json(MutateVpnClientResponse { + client: client_view_with_owner(client, &owner_map), + sync, + }) + .into_response() +} + +async fn set_vpn_client_enabled_handler( + session: Session, + db: Database, + Path(path): Path, + Json(request): Json, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + } + }; + + let Some(mut client) = vpn::VpnClient::get_visible(&db, &user, path.id) + .await + .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? + else { + return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); + }; + + client + .set_enabled(&db, request.enabled) + .await + .map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?; + let (config, _) = AppConfig::load_with_db(&db).await; + let sync = vpn::sync_from_database(&db, &config) + .await + .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + + let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; + Json(MutateVpnClientResponse { + client: client_view_with_owner(client, &owner_map), + sync, + }) + .into_response() +} + +async fn delete_vpn_client_handler( + session: Session, + db: Database, + Path(path): Path, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + } + }; + + let Some(client) = vpn::VpnClient::get_visible(&db, &user, path.id) + .await + .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? + else { + return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); + }; + + vpn::VpnClient::delete_by_id(&db, client.id_val()) + .await + .map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?; + let (config, _) = AppConfig::load_with_db(&db).await; + let sync = vpn::sync_from_database(&db, &config) + .await + .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + + Json(DeleteVpnClientResponse { sync }).into_response() +} + +async fn vpn_client_config_handler( + session: Session, + db: Database, + Path(path): Path, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + } + }; + + let Some(client) = vpn::VpnClient::get_visible(&db, &user, path.id) + .await + .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? + else { + return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); + }; + + let (config, _) = AppConfig::load_with_db(&db).await; + let runtime = vpn::read_runtime_from_kubernetes(&config) + .await + .map_err(|e| cot::Error::internal(format!("failed to read VPN runtime: {e}")))?; + let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config); + if endpoints.is_empty() { + return Ok(json_error( + cot::http::StatusCode::CONFLICT, + "no VPN endpoints are enabled", + )); + }; + + let mut servers = Vec::with_capacity(endpoints.len()); + for endpoint in endpoints { + let config_text = vpn::render_client_config( + &client, + &runtime.server_public_key, + &endpoint.endpoint, + &config, + ); + let qr_payload = vpn::render_vpn_payload( + &client, + &runtime.server_public_key, + &endpoint.display_name, + &endpoint.endpoint, + &config, + ) + .map_err(|e| cot::Error::internal(format!("failed to render VPN QR payload: {e}")))?; + let vpn_url = vpn::render_vpn_url(&qr_payload); + + servers.push(ClientServerConfigResponse { + endpoint_id: endpoint.name, + endpoint_name: endpoint.display_name, + endpoint: endpoint.endpoint, + config: config_text, + qr_svg: render_qr_svg(&qr_payload)?, + qr_payload, + vpn_url, + }); + } + + Json(ClientConfigResponse { + id: client.id_val(), + name: client.name_str().to_owned(), + servers, + }) + .into_response() +} + +#[derive(Debug, Clone)] +struct OwnerView { + username: String, + display_name: String, +} + +async fn owner_view_map( + db: &Database, + clients: &[vpn::VpnClient], +) -> cot::Result> { + let owner_ids = clients + .iter() + .map(vpn::VpnClient::owner_user_id) + .collect::>(); + let users = User::list_all(db) + .await + .map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?; + Ok(users + .into_iter() + .filter(|user| owner_ids.contains(&user.id_val())) + .map(|user| { + ( + user.id_val(), + OwnerView { + username: user.username_str().to_owned(), + display_name: user.display_name_str(), + }, + ) + }) + .collect()) +} + +fn client_view_with_owner( + client: vpn::VpnClient, + owner_map: &HashMap, +) -> vpn::VpnClientView { + let mut view = client.view(); + if let Some(owner) = owner_map.get(&view.owner_user_id) { + view.owner_username = owner.username.clone(); + view.owner_display_name = owner.display_name.clone(); + } + view +} + +fn render_qr_svg(value: &str) -> cot::Result { + let code = QrCode::new(value.as_bytes()) + .map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?; + Ok(code + .render::() + .min_dimensions(256, 256) + .dark_color(svg::Color("#17202a")) + .light_color(svg::Color("#ffffff")) + .build()) +} + +async fn sync_vpn_clients_handler( + session: Session, + db: Database, +) -> cot::Result { + let user = match auth::require_admin_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::FORBIDDEN, + "admin role required", + )); + } + }; + tracing::info!( + admin_user_id = user.id, + "manual client Secret sync requested" + ); + + let (config, _) = AppConfig::load_with_db(&db).await; + let sync = vpn::sync_from_database(&db, &config) + .await + .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + + Json(sync).into_response() +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +pub struct ApiApp; + +impl App for ApiApp { + fn name(&self) -> &'static str { + "api" + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"), + Route::with_api_handler_and_name( + "/vpn-clients", + api_get(vpn_clients_handler).post(create_vpn_client_handler), + "api_vpn_clients", + ), + Route::with_api_handler_and_name( + "/vpn-status", + api_get(vpn_status_handler), + "api_vpn_status", + ), + Route::with_api_handler_and_name( + "/vpn-clients/sync", + api_post(sync_vpn_clients_handler), + "api_vpn_clients_sync", + ), + Route::with_api_handler_and_name( + "/vpn-clients/{id}/enabled", + api_post(set_vpn_client_enabled_handler), + "api_vpn_client_enabled", + ), + Route::with_api_handler_and_name( + "/vpn-clients/{id}", + api_delete(delete_vpn_client_handler), + "api_vpn_client_delete", + ), + Route::with_api_handler_and_name( + "/vpn-clients/{id}/config", + api_get(vpn_client_config_handler), + "api_vpn_client_config", + ), + ]) + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6a90007 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,146 @@ +use cot::Body; +use cot::db::Database; +use cot::response::IntoResponse; +use cot::session::Session; + +use crate::user::User; + +// --------------------------------------------------------------------------- +// Role enum +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Admin, + Client, +} + +impl Role { + pub fn code(self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Client => "client", + } + } + + pub fn from_code(s: &str) -> Option { + match s { + "admin" => Some(Role::Admin), + "client" => Some(Role::Client), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// Session-based auth +// --------------------------------------------------------------------------- + +const SESSION_USER_ID: &str = "user_id"; + +#[derive(Debug, Clone)] +pub struct AuthenticatedUser { + pub id: i64, + pub name: String, + pub role: Role, +} + +/// Read `user_id` from the session, fetch the `User` from DB, return +/// `AuthenticatedUser` if the user exists and is active. +pub async fn get_session_user(session: &Session, db: &Database) -> Option { + let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??; + let user = User::get_by_id(db, user_id).await.ok()??; + if !user.is_active() { + return None; + } + let name = { + let display = user.display_name_str(); + if display.is_empty() { + user.username_str().to_owned() + } else { + display + } + }; + Some(AuthenticatedUser { + id: user.id_val(), + name, + role: user.role(), + }) +} + +/// Return `Ok(user)` if the session belongs to an active admin, otherwise +/// `Err(response)` โ€” a redirect to `/login` or a 403. +pub async fn require_admin_or_redirect( + session: &Session, + db: &Database, +) -> Result { + let user = require_user_or_redirect(session, db).await?; + if user.role != Role::Admin { + return Err("Forbidden" + .with_status(cot::http::StatusCode::FORBIDDEN) + .into_response() + .expect("valid response")); + } + Ok(user) +} + +/// Return `Ok(user)` if the session belongs to an active user, otherwise +/// `Err(response)` - a redirect to `/login`. +pub async fn require_user_or_redirect( + session: &Session, + db: &Database, +) -> Result { + let Some(user) = get_session_user(session, db).await else { + return Err(redirect("/login")); + }; + Ok(user) +} + +/// Insert user_id into the session and cycle the session ID. +pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> { + session + .cycle_id() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_USER_ID, user_id) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +/// Flush (destroy) the session. +pub async fn logout(session: &Session) -> cot::Result<()> { + session + .flush() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +/// Build a 303 See Other redirect response. +pub fn redirect(location: &str) -> cot::response::Response { + cot::http::Response::builder() + .status(cot::http::StatusCode::SEE_OTHER) + .header(cot::http::header::LOCATION, location) + .body(Body::fixed("")) + .expect("valid response") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_roundtrip() { + assert_eq!(Role::from_code("admin"), Some(Role::Admin)); + assert_eq!(Role::from_code("client"), Some(Role::Client)); + assert_eq!(Role::from_code("other"), None); + assert_eq!(Role::Admin.code(), "admin"); + assert_eq!(Role::Client.code(), "client"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..24959d0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,441 @@ +/// Application-level configuration for amnezia-fellow. +/// +/// Every field is available both as an `AMNEZIA_FELLOW_`-prefixed environment +/// variable and through the admin UI. The resolution order is: +/// +/// env var > DB override > compiled default +use std::collections::HashMap; + +use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; +use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model}; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// ConfigSource - tracks where each field's effective value came from +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigSource { + Default, + Database, + Env, +} + +impl ConfigSource { + pub fn code(self) -> &'static str { + match self { + Self::Default => "default", + Self::Database => "database", + Self::Env => "env", + } + } +} + +// --------------------------------------------------------------------------- +// ConfigEntry - DB model for amnezia_fellow__config_entry +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct ConfigEntry { + #[model(primary_key)] + key: String, + value: String, +} + +impl ConfigEntry { + pub fn new(key: String, value: String) -> Self { + Self { key, value } + } +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +pub mod db_migrations { + use super::*; + + #[derive(Debug, Copy, Clone)] + pub struct M0001CreateConfigEntry; + + impl migrations::Migration for M0001CreateConfigEntry { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0001_create_config_entry"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("amnezia_fellow__config_entry")) + .fields(&[ + Field::new( + Identifier::new("key"), + as DatabaseField>::TYPE, + ) + .primary_key() + .set_null( as DatabaseField>::NULLABLE), + Field::new(Identifier::new("value"), ::TYPE) + .set_null(::NULLABLE), + ]) + .build()]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfigEntry]; +} + +// --------------------------------------------------------------------------- +// ConfigSources - parallel struct tracking the source of each field +// --------------------------------------------------------------------------- + +pub struct ConfigSources { + pub database_url: ConfigSource, + pub oidc_issuer: ConfigSource, + pub oidc_client_id: ConfigSource, + pub oidc_client_secret: ConfigSource, + pub log_level: ConfigSource, + pub auth_password_enabled: ConfigSource, + pub auth_sso_enabled: ConfigSource, + pub oidc_button_text: ConfigSource, + pub oidc_admin_groups: ConfigSource, + pub oidc_client_groups: ConfigSource, + pub k8s_namespace: ConfigSource, + pub k8s_clients_secret: ConfigSource, + pub k8s_clients_secret_key: ConfigSource, + pub k8s_server_secret: ConfigSource, + pub k8s_endpoints_secret: ConfigSource, + pub vpn_disabled_endpoints: ConfigSource, + pub vpn_endpoint_name_overrides: ConfigSource, + pub vpn_client_cidr: ConfigSource, + pub vpn_dns: ConfigSource, + pub vpn_mtu: ConfigSource, + pub swagger_enabled: ConfigSource, +} + +impl Default for ConfigSources { + fn default() -> Self { + Self { + database_url: ConfigSource::Default, + oidc_issuer: ConfigSource::Default, + oidc_client_id: ConfigSource::Default, + oidc_client_secret: ConfigSource::Default, + log_level: ConfigSource::Default, + auth_password_enabled: ConfigSource::Default, + auth_sso_enabled: ConfigSource::Default, + oidc_button_text: ConfigSource::Default, + oidc_admin_groups: ConfigSource::Default, + oidc_client_groups: ConfigSource::Default, + k8s_namespace: ConfigSource::Default, + k8s_clients_secret: ConfigSource::Default, + k8s_clients_secret_key: ConfigSource::Default, + k8s_server_secret: ConfigSource::Default, + k8s_endpoints_secret: ConfigSource::Default, + vpn_disabled_endpoints: ConfigSource::Default, + vpn_endpoint_name_overrides: ConfigSource::Default, + vpn_client_cidr: ConfigSource::Default, + vpn_dns: ConfigSource::Default, + vpn_mtu: ConfigSource::Default, + swagger_enabled: ConfigSource::Default, + } + } +} + +// --------------------------------------------------------------------------- +// Env-var helper +// --------------------------------------------------------------------------- + +fn env_override(field: &str) -> Option { + let key = format!("AMNEZIA_FELLOW_{}", field.to_ascii_uppercase()); + match std::env::var(&key) { + Ok(val) => match val.parse::() { + Ok(v) => Some(v), + Err(_) => { + tracing::warn!("ignoring invalid value for {key}: {val:?}"); + None + } + }, + Err(_) => None, + } +} + +// --------------------------------------------------------------------------- +// Macro: generates apply_env_overrides + apply_env_overrides_tracked +// --------------------------------------------------------------------------- + +macro_rules! impl_env_overrides { + ($($field:ident),* $(,)?) => { + impl AppConfig { + pub fn apply_env_overrides(&mut self) { + $( + if let Some(v) = env_override(stringify!($field)) { + self.$field = v; + } + )* + } + + pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) { + $( + if let Some(v) = env_override(stringify!($field)) { + self.$field = v; + sources.$field = ConfigSource::Env; + } + )* + } + } + }; +} + +// --------------------------------------------------------------------------- +// AppConfig +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + /// SQLite connection URL. + pub database_url: String, + /// OIDC issuer URL. + pub oidc_issuer: String, + /// OIDC client ID. + pub oidc_client_id: String, + /// OIDC client secret. + pub oidc_client_secret: String, + /// Tracing log level filter. + pub log_level: String, + /// Whether password-based login is enabled. + pub auth_password_enabled: bool, + /// Whether SSO (OIDC) login is enabled. + pub auth_sso_enabled: bool, + /// Label shown on the SSO login button. + pub oidc_button_text: String, + /// Comma-separated OIDC group names that grant admin role. + pub oidc_admin_groups: String, + /// Comma-separated OIDC group names that grant client role. + pub oidc_client_groups: String, + /// Kubernetes namespace containing Amnezia secrets. + pub k8s_namespace: String, + /// Secret containing rendered client peers. + pub k8s_clients_secret: String, + /// Data key inside `k8s_clients_secret` used for rendered peers. + pub k8s_clients_secret_key: String, + /// Secret containing server-side Amnezia/WireGuard configuration. + pub k8s_server_secret: String, + /// Secret containing node endpoint data. + pub k8s_endpoints_secret: String, + /// Comma-separated endpoint names hidden from users. + pub vpn_disabled_endpoints: String, + /// JSON object mapping endpoint Secret keys to UI display names. + pub vpn_endpoint_name_overrides: String, + /// CIDR from which new client addresses are allocated. + pub vpn_client_cidr: String, + /// DNS servers written to generated client configs. + pub vpn_dns: String, + /// MTU written to generated client configs. + pub vpn_mtu: u16, + /// Whether the Swagger UI is served at /swagger/. + pub swagger_enabled: bool, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(), + oidc_issuer: String::new(), + oidc_client_id: String::new(), + oidc_client_secret: String::new(), + log_level: "info".into(), + auth_password_enabled: true, + auth_sso_enabled: false, + oidc_button_text: "Sign in with SSO".into(), + oidc_admin_groups: String::new(), + oidc_client_groups: String::new(), + k8s_namespace: "amnezia".into(), + k8s_clients_secret: "amneziawg-clients".into(), + k8s_clients_secret_key: "peers.conf".into(), + k8s_server_secret: "amneziawg-server".into(), + k8s_endpoints_secret: "amneziawg-endpoints".into(), + vpn_disabled_endpoints: String::new(), + vpn_endpoint_name_overrides: "{}".into(), + vpn_client_cidr: "10.8.0.0/16".into(), + vpn_dns: "1.1.1.1, 8.8.8.8".into(), + vpn_mtu: 1376, + swagger_enabled: false, + } + } +} + +impl_env_overrides!( + database_url, + oidc_issuer, + oidc_client_id, + oidc_client_secret, + log_level, + auth_password_enabled, + auth_sso_enabled, + oidc_button_text, + oidc_admin_groups, + oidc_client_groups, + k8s_namespace, + k8s_clients_secret, + k8s_clients_secret_key, + k8s_server_secret, + k8s_endpoints_secret, + vpn_disabled_endpoints, + vpn_endpoint_name_overrides, + vpn_client_cidr, + vpn_dns, + vpn_mtu, + swagger_enabled, +); + +impl AppConfig { + /// Build config from defaults, then overlay env vars. Used at startup + /// before the DB is available. + pub fn load() -> Self { + let mut cfg = Self::default(); + cfg.apply_env_overrides(); + cfg + } + + /// Build config with full 3-layer resolution and track each field source. + pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) { + let mut cfg = Self::default(); + let mut sources = ConfigSources::default(); + cfg.apply_db_overrides(db, &mut sources).await; + cfg.apply_env_overrides_tracked(&mut sources); + (cfg, sources) + } + + async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) { + let rows = match ConfigEntry::objects().all(db).await { + Ok(rows) => rows, + Err(e) => { + tracing::warn!("failed to read app config from database: {e}"); + return; + } + }; + + let map: HashMap = rows + .into_iter() + .map(|entry| (entry.key.to_string(), entry.value)) + .collect(); + + macro_rules! apply_db_field { + ($field:ident) => { + if let Some(val) = map.get(stringify!($field)) { + match val.parse() { + Ok(v) => { + self.$field = v; + sources.$field = ConfigSource::Database; + } + Err(_) => { + tracing::warn!( + "ignoring invalid DB config value for {}: {:?}", + stringify!($field), + val, + ); + } + } + } + }; + } + + apply_db_field!(database_url); + apply_db_field!(oidc_issuer); + apply_db_field!(oidc_client_id); + apply_db_field!(oidc_client_secret); + apply_db_field!(log_level); + apply_db_field!(auth_password_enabled); + apply_db_field!(auth_sso_enabled); + apply_db_field!(oidc_button_text); + apply_db_field!(oidc_admin_groups); + apply_db_field!(oidc_client_groups); + apply_db_field!(k8s_namespace); + apply_db_field!(k8s_clients_secret); + apply_db_field!(k8s_clients_secret_key); + apply_db_field!(k8s_server_secret); + apply_db_field!(k8s_endpoints_secret); + apply_db_field!(vpn_disabled_endpoints); + apply_db_field!(vpn_endpoint_name_overrides); + apply_db_field!(vpn_client_cidr); + apply_db_field!(vpn_dns); + apply_db_field!(vpn_mtu); + apply_db_field!(swagger_enabled); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn defaults_are_sane() { + let cfg = AppConfig::default(); + assert_eq!(cfg.database_url, "sqlite://amnezia-fellow.sqlite3?mode=rwc"); + assert_eq!(cfg.log_level, "info"); + assert_eq!(cfg.vpn_client_cidr, "10.8.0.0/16"); + } + + struct EnvGuard { + key: &'static str, + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + unset(self.key); + } + } + } + + // SAFETY: callers hold ENV_LOCK while mutating process-wide env vars. + unsafe fn set(k: &str, v: &str) { + unsafe { std::env::set_var(k, v) }; + } + + unsafe fn unset(k: &str) { + unsafe { std::env::remove_var(k) }; + } + + unsafe fn scoped_set(k: &'static str, v: &str) -> EnvGuard { + unsafe { + set(k, v); + } + EnvGuard { key: k } + } + + #[test] + fn env_override_string_field() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = unsafe { scoped_set("AMNEZIA_FELLOW_OIDC_ISSUER", "https://example.com") }; + let cfg = AppConfig::load(); + assert_eq!(cfg.oidc_issuer, "https://example.com"); + } + + #[test] + fn env_override_bool_field() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = unsafe { scoped_set("AMNEZIA_FELLOW_AUTH_SSO_ENABLED", "true") }; + let cfg = AppConfig::load(); + assert!(cfg.auth_sso_enabled); + } + + #[test] + fn source_tracking_env() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = + unsafe { scoped_set("AMNEZIA_FELLOW_OIDC_ISSUER", "https://tracked.example.com") }; + let mut cfg = AppConfig::default(); + let mut sources = ConfigSources::default(); + cfg.apply_env_overrides_tracked(&mut sources); + assert_eq!(cfg.oidc_issuer, "https://tracked.example.com"); + assert_eq!(sources.oidc_issuer, ConfigSource::Env); + assert_eq!(sources.database_url, ConfigSource::Default); + } + + #[test] + fn config_source_codes() { + assert_eq!(ConfigSource::Default.code(), "default"); + assert_eq!(ConfigSource::Database.code(), "database"); + assert_eq!(ConfigSource::Env.code(), "env"); + } +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..4d49799 --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,224 @@ +mod phrases; + +pub use phrases::Translations; + +use cot::request::RequestHead; +use cot::request::extractors::FromRequestHead; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Lang enum +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Lang { + En, + Ru, +} + +impl Lang { + pub fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::Ru => "ru", + } + } + + pub fn from_code(s: &str) -> Option { + match s { + "en" => Some(Lang::En), + "ru" => Some(Lang::Ru), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// translations! macro +// --------------------------------------------------------------------------- + +macro_rules! translations { + ( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => { + #[derive(Debug)] + pub struct Translations { + pub lang: $crate::i18n::Lang, + $( pub $key: &'static str, )* + } + + static EN: Translations = Translations { + lang: $crate::i18n::Lang::En, + $( $key: $en, )* + }; + + static RU: Translations = Translations { + lang: $crate::i18n::Lang::Ru, + $( $key: $ru, )* + }; + + impl Translations { + pub fn for_lang(lang: $crate::i18n::Lang) -> &'static Self { + match lang { + $crate::i18n::Lang::En => &EN, + $crate::i18n::Lang::Ru => &RU, + } + } + } + }; +} + +pub(crate) use translations; + +// --------------------------------------------------------------------------- +// Cookie helpers +// --------------------------------------------------------------------------- + +const COOKIE_NAME: &str = "amnezia_fellow_lang"; + +/// Build a `Set-Cookie` header value that persists the language choice for 1 year. +pub fn lang_cookie(lang: Lang) -> String { + format!( + "{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", + lang.code() + ) +} + +/// Parse the language cookie from the `Cookie` request header. +fn lang_from_cookie(headers: &cot::http::HeaderMap) -> Option { + let raw = headers.get(cot::http::header::COOKIE)?.to_str().ok()?; + for part in raw.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("amnezia_fellow_lang=") { + return Lang::from_code(value.trim()); + } + } + None +} + +// --------------------------------------------------------------------------- +// Accept-Language parsing +// --------------------------------------------------------------------------- + +/// Parse the Accept-Language header and return the best matching `Lang`. +fn parse_accept_language(header: &str) -> Option { + let mut langs: Vec<(&str, u16)> = header + .split(',') + .filter_map(|part| { + let part = part.trim(); + let (tag, quality) = if let Some((tag, q)) = part.split_once(";q=") { + let q = q.trim().parse::().ok()?; + (tag.trim(), (q * 1000.0) as u16) + } else { + (part, 1000) + }; + Some((tag, quality)) + }) + .collect(); + + langs.sort_by(|a, b| b.1.cmp(&a.1)); + + for (tag, _) in langs { + let primary = tag.split('-').next().unwrap_or(tag); + if let Some(lang) = Lang::from_code(primary) { + return Some(lang); + } + } + None +} + +// --------------------------------------------------------------------------- +// Language resolution +// --------------------------------------------------------------------------- + +fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang { + // 1. Explicit cookie override. + if let Some(lang) = lang_from_cookie(headers) { + return lang; + } + + // 2. Accept-Language header. + if let Some(value) = headers.get(cot::http::header::ACCEPT_LANGUAGE) { + if let Ok(s) = value.to_str() { + if let Some(lang) = parse_accept_language(s) { + return lang; + } + } + } + + // 3. Default. + Lang::En +} + +// --------------------------------------------------------------------------- +// I18n extractor +// --------------------------------------------------------------------------- + +pub struct I18n { + pub t: &'static Translations, +} + +impl FromRequestHead for I18n { + async fn from_request_head(head: &RequestHead) -> cot::Result { + let lang = resolve_lang(&head.headers); + Ok(I18n { + t: Translations::for_lang(lang), + }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lang_roundtrip() { + assert_eq!(Lang::from_code("en"), Some(Lang::En)); + assert_eq!(Lang::from_code("ru"), Some(Lang::Ru)); + assert_eq!(Lang::from_code("de"), None); + assert_eq!(Lang::En.code(), "en"); + assert_eq!(Lang::Ru.code(), "ru"); + } + + #[test] + fn parse_simple_accept_language() { + assert_eq!(parse_accept_language("ru"), Some(Lang::Ru)); + assert_eq!(parse_accept_language("en-US"), Some(Lang::En)); + } + + #[test] + fn parse_weighted_accept_language() { + assert_eq!( + parse_accept_language("en-US,en;q=0.9,ru;q=0.8"), + Some(Lang::En) + ); + assert_eq!( + parse_accept_language("ru-RU,ru;q=0.9,en;q=0.5"), + Some(Lang::Ru) + ); + } + + #[test] + fn parse_unknown_falls_through() { + assert_eq!(parse_accept_language("de;q=1.0,ru;q=0.5"), Some(Lang::Ru)); + assert_eq!(parse_accept_language("de,fr,ja"), None); + } + + #[test] + fn cookie_parsing() { + let mut headers = cot::http::HeaderMap::new(); + headers.insert( + cot::http::header::COOKIE, + "other=x; amnezia_fellow_lang=ru; foo=bar".parse().unwrap(), + ); + assert_eq!(lang_from_cookie(&headers), Some(Lang::Ru)); + } + + #[test] + fn cookie_missing() { + let headers = cot::http::HeaderMap::new(); + assert_eq!(lang_from_cookie(&headers), None); + } +} diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs new file mode 100644 index 0000000..badbfde --- /dev/null +++ b/src/i18n/phrases.rs @@ -0,0 +1,135 @@ +use super::translations; + +translations! { + // Global + site_name: "amnezia-fellow" , "amnezia-fellow"; + + // Navigation / sidebar + nav_admin: "admin" , "ะฐะดะผะธะฝะบะฐ"; + nav_configs: "Configs" , "ะšะพะฝั„ะธะณะธ"; + nav_dashboard: "Dashboard" , "ะŸะฐะฝะตะปัŒ ัƒะฟั€ะฐะฒะปะตะฝะธั"; + nav_debug: "Debug" , "ะžั‚ะปะฐะดะบะฐ"; + nav_servers: "Servers" , "ะกะตั€ะฒะตั€ั‹"; + + admin_close: "Close" , "ะ—ะฐะบั€ั‹ั‚ัŒ"; + + debug_field: "Field" , "ะŸะพะปะต"; + debug_value: "Value" , "ะ—ะฝะฐั‡ะตะฝะธะต"; + debug_source: "Source" , "ะ˜ัั‚ะพั‡ะฝะธะบ"; + + // Navigation (settings) + nav_settings: "Settings" , "ะะฐัั‚ั€ะพะนะบะธ"; + + // Debug page โ€” DB status + debug_db_status: "Database" , "ะ‘ะฐะทะฐ ะดะฐะฝะฝั‹ั…"; + debug_db_connected: "connected" , "ะฟะพะดะบะปัŽั‡ะตะฝะฐ"; + debug_db_error: "error" , "ะพัˆะธะฑะบะฐ"; + + settings_oidc: "OIDC Configuration" , "ะะฐัั‚ั€ะพะนะบะธ OIDC"; + settings_save: "Save" , "ะกะพั…ั€ะฐะฝะธั‚ัŒ"; + settings_saved: "Settings saved." , "ะะฐัั‚ั€ะพะนะบะธ ัะพั…ั€ะฐะฝะตะฝั‹."; + + // Auth settings + settings_auth: "Authentication" , "ะัƒั‚ะตะฝั‚ะธั„ะธะบะฐั†ะธั"; + settings_password_login: "Password login" , "ะ’ั…ะพะด ะฟะพ ะฟะฐั€ะพะปัŽ"; + settings_sso_login: "SSO login" , "ะ’ั…ะพะด ั‡ะตั€ะตะท SSO"; + settings_oidc_button: "SSO button text" , "ะขะตะบัั‚ ะบะฝะพะฟะบะธ SSO"; + + // Login page + login_heading: "Sign in" , "ะ’ั…ะพะด"; + login_username: "Username" , "ะ˜ะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั"; + login_password: "Password" , "ะŸะฐั€ะพะปัŒ"; + login_submit: "Sign in" , "ะ’ะพะนั‚ะธ"; + login_disabled: "Login is currently disabled." , "ะ’ั…ะพะด ัะตะนั‡ะฐั ะพั‚ะบะปัŽั‡ั‘ะฝ."; + login_invalid: "Invalid username or password." , "ะะตะฒะตั€ะฝะพะต ะธะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะธะปะธ ะฟะฐั€ะพะปัŒ."; + + // Logout + nav_logout: "Logout" , "ะ’ั‹ั…ะพะด"; + + // Setup page + setup_heading: "Create Admin Account" , "ะกะพะทะดะฐะฝะธะต ะฐะบะบะฐัƒะฝั‚ะฐ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะฐ"; + setup_username: "Username" , "ะ˜ะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั"; + setup_password: "Password" , "ะŸะฐั€ะพะปัŒ"; + setup_confirm: "Confirm password" , "ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะธะต ะฟะฐั€ะพะปั"; + setup_submit: "Create" , "ะกะพะทะดะฐั‚ัŒ"; + setup_mismatch: "Passwords do not match." , "ะŸะฐั€ะพะปะธ ะฝะต ัะพะฒะฟะฐะดะฐัŽั‚."; + + // OIDC help + settings_oidc_admin_groups: "Admin groups" , "ะ“ั€ัƒะฟะฟั‹ ะฐะดะผะธะฝะธัั‚ั€ะฐั‚ะพั€ะพะฒ"; + settings_oidc_client_groups: "Client groups" , "ะ“ั€ัƒะฟะฟั‹ ะบะปะธะตะฝั‚ะพะฒ"; + + // Kubernetes / VPN settings + settings_kubernetes: "Kubernetes" , "Kubernetes"; + settings_vpn: "VPN" , "VPN"; + + // User management + nav_users: "Users" , "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปะธ"; + users_heading: "Users" , "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปะธ"; + users_add: "Add user" , "ะ”ะพะฑะฐะฒะธั‚ัŒ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั"; + users_username: "Username" , "ะ˜ะผั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั"; + users_email: "Email" , "Email"; + users_display_name: "Display name" , "ะžั‚ะพะฑั€ะฐะถะฐะตะผะพะต ะธะผั"; + users_role: "Role" , "ะ ะพะปัŒ"; + users_active: "Active" , "ะะบั‚ะธะฒะตะฝ"; + users_actions: "Actions" , "ะ”ะตะนัั‚ะฒะธั"; + users_edit: "Edit" , "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ"; + users_delete: "Delete" , "ะฃะดะฐะปะธั‚ัŒ"; + users_delete_confirm: "Are you sure?" , "ะ’ั‹ ัƒะฒะตั€ะตะฝั‹?"; + users_new_heading: "New user" , "ะะพะฒั‹ะน ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒ"; + users_edit_heading: "Edit user" , "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั"; + users_password_hint: "Leave blank to keep current" , "ะžัั‚ะฐะฒัŒั‚ะต ะฟัƒัั‚ั‹ะผ, ั‡ั‚ะพะฑั‹ ะฝะต ะผะตะฝัั‚ัŒ"; + // VPN configs + configs_heading: "VPN configs" , "VPN-ะบะพะฝั„ะธะณะธ"; + configs_name: "Name" , "ะ˜ะผั"; + configs_owner: "Owner" , "ะ’ะปะฐะดะตะปะตั†"; + configs_address: "Address" , "ะะดั€ะตั"; + configs_public_key: "Public key" , "ะŸัƒะฑะปะธั‡ะฝั‹ะน ะบะปัŽั‡"; + configs_enabled: "Enabled" , "ะ’ะบะปัŽั‡ั‘ะฝ"; + configs_create: "Create" , "ะกะพะทะดะฐั‚ัŒ"; + configs_sync: "Sync Secret" , "ะกะธะฝั…ั€ะพะฝะธะทะธั€ะพะฒะฐั‚ัŒ Secret"; + configs_rollout_heading: "Apply status" , "ะกั‚ะฐั‚ัƒั ะฟั€ะธะผะตะฝะตะฝะธั"; + configs_refresh: "Refresh" , "ะžะฑะฝะพะฒะธั‚ัŒ"; + configs_config_updated: "Config updated" , "ะšะพะฝั„ะธะณ ะพะฑะฝะพะฒะปั‘ะฝ"; + configs_loading_status: "Loading status..." , "ะ—ะฐะณั€ัƒะทะบะฐ ัั‚ะฐั‚ัƒัะฐ..."; + configs_no_pods: "No AmneziaWG pods." , "ะะตั‚ ะฟะพะดะพะฒ AmneziaWG."; + configs_server: "Server" , "ะกะตั€ะฒะตั€"; + configs_pod: "Pod" , "Pod"; + configs_rollout: "Rollout" , "ะŸั€ะธะผะตะฝะตะฝะธะต"; + configs_ready: "Ready" , "ะ“ะพั‚ะพะฒ"; + configs_not_ready: "Not ready" , "ะะต ะณะพั‚ะพะฒ"; + configs_phase: "Phase" , "ะคะฐะทะฐ"; + configs_uptime: "Uptime" , "ะะฟั‚ะฐะนะผ"; + configs_restarts: "Restarts" , "ะ ะตัั‚ะฐั€ั‚ั‹"; + configs_status_applied: "applied" , "ะฟั€ะธะผะตะฝั‘ะฝ"; + configs_status_starting: "starting" , "ัั‚ะฐั€ั‚ัƒะตั‚"; + configs_status_pending_restart: "pending restart" , "ะถะดั‘ั‚ ั€ะตัั‚ะฐั€ั‚"; + configs_status_unknown: "unknown" , "ะฝะตะธะทะฒะตัั‚ะฝะพ"; + configs_never: "n/a" , "ะฝ/ะด"; + configs_servers: "Servers" , "ะกะตั€ะฒะตั€ั‹"; + configs_loading_servers: "Loading servers..." , "ะ—ะฐะณั€ัƒะทะบะฐ ัะตั€ะฒะตั€ะพะฒ..."; + configs_no_servers: "No registered servers." , "ะะตั‚ ะทะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ั… ัะตั€ะฒะตั€ะพะฒ."; + configs_server_search: "Filter servers" , "ะคะธะปัŒั‚ั€ ัะตั€ะฒะตั€ะพะฒ"; + configs_download: "Download" , "ะกะบะฐั‡ะฐั‚ัŒ"; + configs_copy_vpn_url: "Copy vpn://" , "ะšะพะฟะธั€ะพะฒะฐั‚ัŒ vpn://"; + configs_vpn_url_copied: "VPN link copied" , "VPN-ััั‹ะปะบะฐ ัะบะพะฟะธั€ะพะฒะฐะฝะฐ"; + configs_enable: "Enable" , "ะ’ะบะปัŽั‡ะธั‚ัŒ"; + configs_disable: "Disable" , "ะžั‚ะบะปัŽั‡ะธั‚ัŒ"; + configs_yes: "yes" , "ะดะฐ"; + configs_no: "no" , "ะฝะตั‚"; + configs_empty: "No configs yet." , "ะšะพะฝั„ะธะณะพะฒ ะฟะพะบะฐ ะฝะตั‚."; + configs_name_placeholder: "Client name" , "ะ˜ะผั ะบะปะธะตะฝั‚ะฐ"; + + // VPN server management + servers_empty: "No registered servers." , "ะะตั‚ ะทะฐั€ะตะณะธัั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ั… ัะตั€ะฒะตั€ะพะฒ."; + servers_display_name: "Display name" , "ะžั‚ะพะฑั€ะฐะถะฐะตะผะพะต ะธะผั"; + servers_technical_name: "Technical name" , "ะขะตั…ะฝะธั‡ะตัะบะพะต ะธะผั"; + servers_endpoint: "Endpoint" , "Endpoint"; + + // API settings + settings_api: "API" , "API"; + settings_swagger: "Swagger UI" , "Swagger UI"; + + // OIDC login errors + login_oidc_error: "SSO login failed. Please try again." , "ะžัˆะธะฑะบะฐ ะฒั…ะพะดะฐ ั‡ะตั€ะตะท SSO. ะŸะพะฟั€ะพะฑัƒะนั‚ะต ะตั‰ั‘ ั€ะฐะท."; + login_sso_disabled: "SSO login is not configured." , "ะ’ั…ะพะด ั‡ะตั€ะตะท SSO ะฝะต ะฝะฐัั‚ั€ะพะตะฝ."; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..724eb83 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,384 @@ +mod admin; +mod api; +mod auth; +mod config; +mod i18n; +mod oidc; +mod user; +mod vpn; + +use std::sync::Arc; + +use cot::auth::PasswordVerificationResult; +use cot::cli::CliMetadata; +use cot::common_types::Password; +use cot::config::{ + DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, + SessionStoreTypeConfig, +}; +use cot::db::Database; +use cot::form::{Form, FormResult}; +use cot::html::Html; +use cot::middleware::SessionMiddleware; +use cot::project::RegisterAppsContext; +use cot::request::extractors::{RequestForm, UrlQuery}; +use cot::response::IntoResponse; +use cot::router::method::get; +use cot::router::{Route, Router}; +use cot::session::Session; +use cot::static_files::StaticFilesMiddleware; +use cot::{App, AppBuilder, Body, Project, Template}; +use serde::Deserialize; + +use crate::config::AppConfig; +use crate::i18n::{I18n, Translations}; +use crate::user::User; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async fn index(session: Session, db: Database) -> cot::Result { + if auth::get_session_user(&session, &db).await.is_none() { + return Ok(auth::redirect("/login")); + } + Ok(auth::redirect("/configs")) +} + +#[derive(Debug, Template)] +#[template(path = "configs.html")] +struct ConfigsTemplate { + t: &'static Translations, + user_name: String, + user_role: String, + is_admin: bool, + app_version: &'static str, +} + +async fn configs_page( + session: Session, + db: Database, + i18n: I18n, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(response) => return Ok(response), + }; + Html::new( + ConfigsTemplate { + t: i18n.t, + user_name: user.name, + user_role: user.role.code().to_owned(), + is_admin: user.role == auth::Role::Admin, + app_version: env!("CARGO_PKG_VERSION"), + } + .render()?, + ) + .into_response() +} + +#[derive(Deserialize)] +struct SetLangQuery { + lang: String, + next: Option, +} + +async fn set_lang( + UrlQuery(query): UrlQuery, +) -> cot::Result> { + let lang = i18n::Lang::from_code(&query.lang).unwrap_or(i18n::Lang::En); + let next = query.next.as_deref().unwrap_or("/"); + + let response = cot::http::Response::builder() + .status(cot::http::StatusCode::SEE_OTHER) + .header(cot::http::header::LOCATION, next) + .header(cot::http::header::SET_COOKIE, i18n::lang_cookie(lang)) + .body(Body::fixed("")) + .expect("valid response"); + + Ok(response) +} + +// --------------------------------------------------------------------------- +// Login page +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "login.html")] +struct LoginTemplate { + t: &'static Translations, + auth_password_enabled: bool, + auth_sso_enabled: bool, + oidc_button_text: String, + message: String, +} + +async fn login_page_handler( + i18n: I18n, + _startup_config: &AppConfig, + db: Database, + message: String, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(&db).await; + let template = LoginTemplate { + t: i18n.t, + auth_password_enabled: config.auth_password_enabled, + auth_sso_enabled: config.auth_sso_enabled, + oidc_button_text: config.oidc_button_text, + message, + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +struct LoginForm { + username: String, + password: String, +} + +// --------------------------------------------------------------------------- +// Logout +// --------------------------------------------------------------------------- + +async fn logout_handler(session: Session) -> cot::Result { + auth::logout(&session).await?; + Ok(auth::redirect("/login")) +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +struct AmneziaFellowApp { + config: Arc, +} + +impl App for AmneziaFellowApp { + fn name(&self) -> &'static str { + env!("CARGO_PKG_NAME") + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_handler_and_name( + "/admin", + get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }), + "admin_redirect", + ), + Route::with_handler_and_name( + "/swagger", + get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }), + "swagger_redirect", + ), + Route::with_handler_and_name("/", index, "index"), + Route::with_handler_and_name("/configs", get(configs_page), "configs"), + Route::with_handler_and_name( + "/login", + get({ + let config = Arc::clone(&self.config); + move |i18n: I18n, db: Database| { + let config = Arc::clone(&config); + async move { + // No users at all โ†’ redirect to first-run setup + if User::count_all(&db).await.unwrap_or(0) == 0 { + return Ok(auth::redirect("/admin/setup")); + } + login_page_handler(i18n, &config, db, String::new()) + .await? + .into_response() + } + } + }) + .post({ + let config = Arc::clone(&self.config); + move |i18n: I18n, + db: Database, + session: Session, + form: RequestForm| { + let config = Arc::clone(&config); + async move { + let RequestForm(result) = form; + let data = match result { + FormResult::Ok(data) => data, + FormResult::ValidationError(_) => { + let msg = i18n.t.login_invalid.to_owned(); + return login_page_handler(i18n, &config, db, msg) + .await? + .into_response(); + } + }; + + // Try to authenticate + if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await + { + if let Some(hash) = user.password_ref() { + let password = Password::new(&data.password); + match hash.verify(&password) { + PasswordVerificationResult::Ok + | PasswordVerificationResult::OkObsolete(_) => { + auth::login(&session, user.id_val()).await?; + return Ok(auth::redirect("/")); + } + PasswordVerificationResult::Invalid => {} + } + } + } + + let msg = i18n.t.login_invalid.to_owned(); + login_page_handler(i18n, &config, db, msg) + .await? + .into_response() + } + } + }), + "login", + ), + Route::with_handler_and_name("/logout", get(logout_handler), "logout"), + Route::with_handler_and_name("/set-lang", set_lang, "set_lang"), + Route::with_handler_and_name( + "/auth/oidc/start", + get(oidc::oidc_start_handler), + "oidc_start", + ), + Route::with_handler_and_name( + "/auth/oidc/callback", + get(oidc::oidc_callback_handler), + "oidc_callback", + ), + ]) + } +} + +// --------------------------------------------------------------------------- +// Project +// --------------------------------------------------------------------------- + +struct AmneziaFellowProject { + app_config: Arc, +} + +impl Project for AmneziaFellowProject { + fn cli_metadata(&self) -> CliMetadata { + CliMetadata { + description: concat!( + env!("CARGO_PKG_DESCRIPTION"), + "\n\n", + "CONFIGURATION\n", + " All settings are available as AMNEZIA_FELLOW_-prefixed environment variables.\n", + " Priority: env var > DB override > compiled default.\n", + "\n", + " Database (required for most features):\n", + " AMNEZIA_FELLOW_DATABASE_URL SQLite connection URL\n", + " Example: sqlite:///data/amnezia-fellow.sqlite3?mode=rwc\n", + "\n", + " Server:\n", + " AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n", + "\n", + " Authentication:\n", + " AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED Enable password login (default: true)\n", + " AMNEZIA_FELLOW_AUTH_SSO_ENABLED Enable SSO/OIDC login (default: false)\n", + " AMNEZIA_FELLOW_OIDC_ISSUER OIDC issuer URL\n", + " AMNEZIA_FELLOW_OIDC_CLIENT_ID OIDC client ID\n", + " AMNEZIA_FELLOW_OIDC_CLIENT_SECRET OIDC client secret\n", + " AMNEZIA_FELLOW_OIDC_BUTTON_TEXT SSO button label\n", + " AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n", + " AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS OIDC groups that grant client role\n", + "\n", + " Kubernetes:\n", + " AMNEZIA_FELLOW_K8S_NAMESPACE Namespace with Amnezia secrets\n", + " AMNEZIA_FELLOW_K8S_CLIENTS_SECRET Client peer Secret name\n", + " AMNEZIA_FELLOW_VPN_CLIENT_CIDR Client address pool\n", + "\n", + " API:\n", + " AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n", + "\n", + "QUICK START\n", + " export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n", + " amnezia-fellow --listen 127.0.0.1:8000", + ), + ..cot::cli::metadata!() + } + } + + fn config(&self, _config_name: &str) -> cot::Result { + let mut builder = ProjectConfig::builder(); + builder.debug(cfg!(debug_assertions)); + + if !self.app_config.database_url.is_empty() { + builder.database( + DatabaseConfig::builder() + .url(self.app_config.database_url.as_str()) + .build(), + ); + builder.middlewares( + MiddlewareConfig::builder() + .session( + SessionMiddlewareConfig::builder() + .store( + SessionStoreConfig::builder() + .store_type(SessionStoreTypeConfig::Database) + .build(), + ) + .build(), + ) + .build(), + ); + } + + Ok(builder.build()) + } + + fn middlewares( + &self, + handler: cot::project::RootHandlerBuilder, + context: &cot::project::MiddlewareContext, + ) -> cot::project::RootHandler { + handler + .middleware(StaticFilesMiddleware::from_context(context)) + .middleware( + SessionMiddleware::from_context(context).same_site(cot::config::SameSite::Lax), + ) + .build() + } + + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + apps.register(cot::session::db::SessionApp::new()); + apps.register_with_views( + AmneziaFellowApp { + config: Arc::clone(&self.app_config), + }, + "", + ); + apps.register_with_views(admin::AdminApp::new(), "/admin"); + apps.register_with_views(api::ApiApp, "/api"); + if self.app_config.swagger_enabled { + apps.register_with_views(cot::openapi::swagger_ui::SwaggerUi::new(), "/swagger"); + } + } +} + +// --------------------------------------------------------------------------- +// Entrypoint +// --------------------------------------------------------------------------- + +#[cot::main] +fn main() -> impl Project { + let app_config = Arc::new(AppConfig::load()); + + // Initialise tracing subscriber with the configured log level. + // AMNEZIA_FELLOW_LOG_LEVEL (or the default "info") is parsed as an + // EnvFilter directive, so values like "debug" all work. + let filter = + tracing_subscriber::EnvFilter::try_new(&app_config.log_level).unwrap_or_else(|e| { + eprintln!( + "WARNING: invalid AMNEZIA_FELLOW_LOG_LEVEL {:?}: {e}; falling back to \"info\"", + app_config.log_level, + ); + tracing_subscriber::EnvFilter::new("info") + }); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + tracing::info!("loaded config: {:?}", app_config); + + AmneziaFellowProject { app_config } +} diff --git a/src/oidc.rs b/src/oidc.rs new file mode 100644 index 0000000..52cfc13 --- /dev/null +++ b/src/oidc.rs @@ -0,0 +1,589 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::LazyLock; +use std::time::Instant; + +use cot::db::Database; +use cot::session::Session; +use openidconnect::core::{CoreClient, CoreProviderMetadata}; +use openidconnect::{ + AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet, + EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, +}; + +use cot::request::RequestHead; +use cot::request::extractors::FromRequestHead; + +use crate::auth; +use crate::config::AppConfig; +use crate::i18n::I18n; +use crate::user::{OidcLink, User}; + +// --------------------------------------------------------------------------- +// Request origin extractor (scheme + host from headers) +// --------------------------------------------------------------------------- + +/// Extracts the origin (e.g. "http://127.0.0.1:3001") from the request so we +/// can build the correct OIDC redirect URI. +pub struct RequestOrigin(pub String); + +impl FromRequestHead for RequestOrigin { + async fn from_request_head(head: &RequestHead) -> cot::Result { + let scheme = head + .headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("http"); + + let host = head + .headers + .get(cot::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + + Ok(RequestOrigin(format!("{scheme}://{host}"))) + } +} + +// --------------------------------------------------------------------------- +// Session keys for OIDC flow state +// --------------------------------------------------------------------------- + +const SESSION_CSRF_STATE: &str = "oidc_csrf_state"; +const SESSION_NONCE: &str = "oidc_nonce"; +const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier"; +const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri"; + +// --------------------------------------------------------------------------- +// Provider cache +// --------------------------------------------------------------------------- + +/// Concrete client type returned by `from_provider_metadata` + `set_redirect_uri`. +/// The provider metadata discovery sets auth URL to EndpointSet, and token/userinfo +/// endpoints to EndpointMaybeSet. The remaining endpoints stay EndpointNotSet. +type ConfiguredClient = CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +struct CachedProvider { + client: ConfiguredClient, + fetched_at: Instant, + config_hash: u64, +} + +static PROVIDER_CACHE: LazyLock>> = + LazyLock::new(|| tokio::sync::RwLock::new(None)); + +/// TTL for cached provider metadata (1 hour). +const PROVIDER_TTL_SECS: u64 = 3600; + +/// Compute a hash of the OIDC configuration values so we can detect changes. +fn config_hash(issuer: &str, client_id: &str, client_secret: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + issuer.hash(&mut hasher); + client_id.hash(&mut hasher); + client_secret.hash(&mut hasher); + hasher.finish() +} + +fn oidc_http_client() -> reqwest::Client { + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("valid reqwest client") +} + +/// Get or refresh the cached OIDC provider. Returns a cloned `ConfiguredClient`. +async fn get_or_refresh_provider( + config: &AppConfig, + http: &reqwest::Client, +) -> Result { + let hash = config_hash( + &config.oidc_issuer, + &config.oidc_client_id, + &config.oidc_client_secret, + ); + + // Fast path: check if we have a valid cached provider. + { + let cache = PROVIDER_CACHE.read().await; + if let Some(ref cached) = *cache { + if cached.config_hash == hash + && cached.fetched_at.elapsed().as_secs() < PROVIDER_TTL_SECS + { + return Ok(cached.client.clone()); + } + } + } + + // Slow path: discover provider metadata + JWKS. + // Strip /.well-known/openid-configuration suffix if the user pasted the + // full discovery URL, so discover_async doesn't double-append it. + let issuer = config + .oidc_issuer + .trim_end_matches('/') + .strip_suffix("/.well-known/openid-configuration") + .unwrap_or(config.oidc_issuer.trim_end_matches('/')) + .to_owned(); + + let issuer_url = IssuerUrl::new(issuer).map_err(|e| format!("invalid issuer URL: {e}"))?; + + let metadata = CoreProviderMetadata::discover_async(issuer_url, http) + .await + .map_err(|e| format!("OIDC discovery failed: {e}"))?; + + let client = CoreClient::from_provider_metadata( + metadata, + ClientId::new(config.oidc_client_id.clone()), + Some(ClientSecret::new(config.oidc_client_secret.clone())), + ); + + let mut cache = PROVIDER_CACHE.write().await; + *cache = Some(CachedProvider { + client: client.clone(), + fetched_at: Instant::now(), + config_hash: hash, + }); + + Ok(client) +} + +// --------------------------------------------------------------------------- +// GET /auth/oidc/start +// --------------------------------------------------------------------------- + +pub async fn oidc_start_handler( + origin: RequestOrigin, + i18n: I18n, + db: Database, + session: Session, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(&db).await; + + // Validate SSO is enabled and configured. + if !config.auth_sso_enabled + || config.oidc_issuer.is_empty() + || config.oidc_client_id.is_empty() + || config.oidc_client_secret.is_empty() + { + tracing::warn!("OIDC start requested but SSO is not configured"); + return redirect_login_with_error(i18n.t.login_sso_disabled); + } + + let http = oidc_http_client(); + let client = match get_or_refresh_provider(&config, &http).await { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC provider error: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Build redirect URI from the actual request origin. + let redirect_uri_str = format!("{}/auth/oidc/callback", origin.0); + let redirect_url = RedirectUrl::new(redirect_uri_str.clone()) + .map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?; + let client = client.set_redirect_uri(redirect_url); + + // Build PKCE challenge. + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Build authorization URL. + // The openid scope is added automatically by the crate; only add email + profile. + let (auth_url, csrf_state, nonce) = client + .authorize_url( + openidconnect::AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + // Store OIDC flow state in the session. + session + .insert(SESSION_CSRF_STATE, csrf_state.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_NONCE, nonce.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_PKCE_VERIFIER, pkce_verifier.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_REDIRECT_URI, redirect_uri_str) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Ok(auth::redirect(auth_url.as_str())) +} + +// --------------------------------------------------------------------------- +// GET /auth/oidc/callback +// --------------------------------------------------------------------------- + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct OidcCallbackQuery { + code: String, + state: String, +} + +pub async fn oidc_callback_handler( + i18n: I18n, + db: Database, + session: Session, + cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery< + OidcCallbackQuery, + >, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(&db).await; + + // Retrieve OIDC flow state from the session. + let saved_csrf: Option = session + .get(SESSION_CSRF_STATE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_nonce: Option = session + .get(SESSION_NONCE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_pkce: Option = session + .get(SESSION_PKCE_VERIFIER) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_redirect_uri: Option = session + .get(SESSION_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + // Validate CSRF state. + let Some(saved_csrf) = saved_csrf else { + tracing::warn!("OIDC callback: no CSRF state in session"); + return redirect_login_with_error(i18n.t.login_oidc_error); + }; + if query.state != saved_csrf { + tracing::warn!("OIDC callback: CSRF state mismatch"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + + let Some(nonce_str) = saved_nonce else { + tracing::warn!("OIDC callback: no nonce in session"); + return redirect_login_with_error(i18n.t.login_oidc_error); + }; + let Some(pkce_str) = saved_pkce else { + tracing::warn!("OIDC callback: no PKCE verifier in session"); + return redirect_login_with_error(i18n.t.login_oidc_error); + }; + + let nonce = Nonce::new(nonce_str); + let pkce_verifier = PkceCodeVerifier::new(pkce_str); + + let http = oidc_http_client(); + let client = match get_or_refresh_provider(&config, &http).await { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC provider error during callback: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Restore the redirect URI that was used in the authorization request. + let client = if let Some(ref uri) = saved_redirect_uri { + let redirect_url = RedirectUrl::new(uri.clone()) + .map_err(|e| cot::Error::internal(format!("bad redirect URI from session: {e}")))?; + client.set_redirect_uri(redirect_url) + } else { + client + }; + + // Exchange code for tokens. + let token_request = match client.exchange_code(AuthorizationCode::new(query.code.clone())) { + Ok(req) => req, + Err(e) => { + tracing::error!("OIDC token endpoint not configured: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + let token_response = token_request + .set_pkce_verifier(pkce_verifier) + .request_async(&http) + .await; + + let token_response = match token_response { + Ok(t) => t, + Err(e) => { + tracing::error!("OIDC token exchange failed: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Verify and extract ID token claims. + use openidconnect::TokenResponse; + let id_token = match token_response.id_token() { + Some(t) => t, + None => { + tracing::error!("OIDC response missing ID token"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + let claims = match id_token.claims(&client.id_token_verifier(), &nonce) { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC ID token verification failed: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + let sub = claims.subject().to_string(); + let issuer = claims.issuer().to_string(); + let email = claims.email().map(|e| e.to_string()); + let name = claims + .name() + .and_then(|n| n.get(None)) + .map(|n| n.to_string()); + + // Extract groups from the raw JWT payload (second dot-separated segment). + // The token is already signature-verified above, so we only need to decode + // the payload to read the non-standard `groups` claim. + let groups: Vec = (|| { + use base64::Engine; + let raw = id_token.to_string(); + let payload_b64 = raw.split('.').nth(1)?; + // JWT payloads use URL-safe base64; try without padding first, then + // fall back to the padded variant (some providers add trailing '='). + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + let arr = value.get("groups")?.as_array()?; + Some( + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + ) + })() + .unwrap_or_default(); + + tracing::info!( + "OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, client_groups={:?}", + config.oidc_admin_groups, + config.oidc_client_groups, + ); + + // User provisioning logic. + let user = match provision_user( + &db, + &issuer, + &sub, + email.as_deref(), + name.as_deref(), + &groups, + &config.oidc_admin_groups, + &config.oidc_client_groups, + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!("OIDC user provisioning failed: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Log the user in. + auth::login(&session, user.id_val()).await?; + + // Clear OIDC session keys. + let _: Option = session + .remove(SESSION_CSRF_STATE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_NONCE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_PKCE_VERIFIER) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Ok(auth::redirect("/")) +} + +// --------------------------------------------------------------------------- +// User provisioning +// --------------------------------------------------------------------------- + +/// Resolve the role based on strict OIDC group membership. +/// Users outside both configured group sets are denied. +fn resolve_role( + groups: &[String], + admin_groups: &str, + client_groups: &str, +) -> Result<&'static str, String> { + let admin_set: std::collections::HashSet<&str> = admin_groups + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + for g in groups { + if admin_set.contains(g.as_str()) { + return Ok(auth::Role::Admin.code()); + } + } + + let client_set: std::collections::HashSet<&str> = client_groups + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + for g in groups { + if client_set.contains(g.as_str()) { + return Ok(auth::Role::Client.code()); + } + } + + Err("OIDC user is not a member of an allowed group".to_owned()) +} + +async fn provision_user( + db: &Database, + issuer: &str, + sub: &str, + email: Option<&str>, + name: Option<&str>, + groups: &[String], + admin_groups: &str, + client_groups: &str, +) -> Result { + let role = resolve_role(groups, admin_groups, client_groups)?; + + // 1. Check for existing OIDC link. + if let Some(mut link) = OidcLink::find_by_issuer_sub(db, issuer, sub) + .await + .map_err(|e| format!("DB error finding OIDC link: {e}"))? + { + // Fetch the linked user. + match User::get_by_id(db, link.user_id()).await { + Ok(Some(mut user)) => { + // Update cached claims. + link.update_claims(db, email, name) + .await + .map_err(|e| format!("DB error updating OIDC link: {e}"))?; + + // Always update role on login. + user.update_role(db, role) + .await + .map_err(|e| format!("DB error updating user role: {e}"))?; + + return Ok(user); + } + Ok(None) => { + // User was deleted but the OIDC link is stale โ€” remove it + // and fall through to re-create the user below. + tracing::warn!( + "OIDC link points to deleted user {}; removing stale link", + link.user_id(), + ); + link.delete(db) + .await + .map_err(|e| format!("DB error deleting stale OIDC link: {e}"))?; + } + Err(e) => return Err(format!("DB error fetching user: {e}")), + } + } + + // 2. No existing link โ€” try to find a user by email. + if let Some(email_str) = email { + if let Some(mut user) = User::get_by_email(db, email_str) + .await + .map_err(|e| format!("DB error finding user by email: {e}"))? + { + // Create OIDC link for existing user. + OidcLink::create_link(db, user.id_val(), issuer, sub, email, name) + .await + .map_err(|e| format!("DB error creating OIDC link: {e}"))?; + + user.update_role(db, role) + .await + .map_err(|e| format!("DB error updating user role: {e}"))?; + + return Ok(user); + } + } + + // 3. Create a brand-new user + OIDC link. + // Generate a unique username from the sub or email. + let username = if let Some(email_str) = email { + email_str.split('@').next().unwrap_or(sub).to_owned() + } else { + sub.to_owned() + }; + + // Ensure username uniqueness by appending a suffix if needed. + let mut candidate = username.clone(); + let mut suffix = 0u32; + loop { + match User::get_by_username(db, &candidate).await { + Ok(None) => break, + Ok(Some(_)) => { + suffix += 1; + candidate = format!("{username}_{suffix}"); + } + Err(e) => return Err(format!("DB error checking username: {e}")), + } + } + + let user = User::create_oidc(db, &candidate, email, name, role) + .await + .map_err(|e| format!("DB error creating user: {e}"))?; + + OidcLink::create_link(db, user.id_val(), issuer, sub, email, name) + .await + .map_err(|e| format!("DB error creating OIDC link: {e}"))?; + + Ok(user) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn redirect_login_with_error(message: &str) -> cot::Result { + let encoded = urlencoded(message); + Ok(auth::redirect(&format!("/login?error={encoded}"))) +} + +/// Minimal percent-encoding for query parameter values. +fn urlencoded(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push_str(&format!("{b:02X}")); + } + } + } + out +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..e711692 --- /dev/null +++ b/src/user.rs @@ -0,0 +1,399 @@ +use cot::auth::PasswordHash; +use cot::common_types::Password; +use cot::db::{Auto, Database, LimitedString, Model}; + +// --------------------------------------------------------------------------- +// User model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct User { + #[model(primary_key)] + id: Auto, + #[model(unique)] + username: LimitedString<255>, + password: Option, + email: Option, + display_name: Option, + avatar_url: Option, + role: LimitedString<32>, + is_active: bool, +} + +// --------------------------------------------------------------------------- +// User helper methods +// --------------------------------------------------------------------------- + +impl User { + /// List all users. + pub async fn list_all(db: &Database) -> cot::db::Result> { + Self::objects().all(db).await + } + + /// Get a user by primary key. + pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result> { + Self::get_by_primary_key(db, Auto::Fixed(user_id)).await + } + + /// Create a new user and insert it into the database. + pub async fn create( + db: &Database, + username: &str, + email: Option<&str>, + display_name: Option<&str>, + password: &str, + role: &str, + ) -> cot::db::Result { + let hash = PasswordHash::from_password(&Password::new(password)); + let mut user = Self { + id: Auto::auto(), + username: LimitedString::new(username).unwrap(), + password: Some(hash.into_string()), + email: email.map(str::to_owned), + display_name: display_name.map(str::to_owned), + avatar_url: None, + role: LimitedString::new(role).unwrap(), + is_active: true, + }; + user.insert(db).await?; + Ok(user) + } + + /// Create a user without a password (for OIDC-only accounts). + pub async fn create_oidc( + db: &Database, + username: &str, + email: Option<&str>, + display_name: Option<&str>, + role: &str, + ) -> cot::db::Result { + let mut user = Self { + id: Auto::auto(), + username: LimitedString::new(username).unwrap(), + password: None, + email: email.map(str::to_owned), + display_name: display_name.map(str::to_owned), + avatar_url: None, + role: LimitedString::new(role).unwrap(), + is_active: true, + }; + user.insert(db).await?; + Ok(user) + } + + /// Update an existing user. If `new_password` is `Some`, the password hash + /// is replaced; otherwise the existing hash is kept. + pub async fn update_fields( + &mut self, + db: &Database, + username: &str, + email: Option<&str>, + display_name: Option<&str>, + new_password: Option<&str>, + role: &str, + ) -> cot::db::Result<()> { + self.username = LimitedString::new(username).unwrap(); + self.email = email.map(str::to_owned); + self.display_name = display_name.map(str::to_owned); + if let Some(pw) = new_password { + self.password = Some(PasswordHash::from_password(&Password::new(pw)).into_string()); + } + self.role = LimitedString::new(role).unwrap(); + self.save(db).await + } + + /// Look up a user by username. + pub async fn get_by_username(db: &Database, username: &str) -> cot::db::Result> { + let Ok(username) = LimitedString::<255>::new(username) else { + return Ok(None); + }; + cot::db::query!(User, $username == username).get(db).await + } + + /// Find a user by email address. + pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result> { + let email = email.to_owned(); + cot::db::query!(User, $email == Some(email)).get(db).await + } + + /// Count all users in the database. + pub async fn count_all(db: &Database) -> cot::db::Result { + Self::objects().count(db).await + } + + /// Return a reference to the password hash, if set. + pub fn password_ref(&self) -> Option { + self.password + .as_ref() + .and_then(|hash| PasswordHash::new(hash.clone()).ok()) + } + + /// Parse the stored role code into a `Role`, defaulting to `Client`. + pub fn role(&self) -> crate::auth::Role { + crate::auth::Role::from_code(&self.role).unwrap_or(crate::auth::Role::Client) + } + + /// Update the user's role and persist the change. + pub async fn update_role(&mut self, db: &Database, role: &str) -> cot::db::Result<()> { + self.role = LimitedString::new(role).unwrap(); + self.save(db).await + } + + /// Delete this user by primary key. + pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> { + cot::db::query!(User, $id == Auto::Fixed(user_id)) + .delete(db) + .await?; + Ok(()) + } + + // Accessor helpers for templates + pub fn id_val(&self) -> i64 { + self.id.unwrap() + } + + pub fn username_str(&self) -> &str { + &self.username + } + + pub fn email_str(&self) -> String { + self.email.clone().unwrap_or_default() + } + + pub fn display_name_str(&self) -> String { + self.display_name.clone().unwrap_or_default() + } + + pub fn role_str(&self) -> &str { + &self.role + } + + pub fn is_active(&self) -> bool { + self.is_active + } +} + +// --------------------------------------------------------------------------- +// OidcLink model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct OidcLink { + #[model(primary_key)] + id: Auto, + user_id: i64, + issuer: LimitedString<255>, + sub: LimitedString<255>, + email: Option, + name: Option, + avatar_url: Option, +} + +// --------------------------------------------------------------------------- +// OidcLink helper methods +// --------------------------------------------------------------------------- + +impl OidcLink { + /// Find an OIDC link by issuer + subject. + pub async fn find_by_issuer_sub( + db: &Database, + issuer: &str, + sub: &str, + ) -> cot::db::Result> { + let Ok(issuer) = LimitedString::<255>::new(issuer) else { + return Ok(None); + }; + let Ok(sub) = LimitedString::<255>::new(sub) else { + return Ok(None); + }; + cot::db::query!(OidcLink, $issuer == issuer && $sub == sub) + .get(db) + .await + } + + /// Create a new OIDC link for a user. + pub async fn create_link( + db: &Database, + user_id: i64, + issuer: &str, + sub: &str, + email: Option<&str>, + name: Option<&str>, + ) -> cot::db::Result { + let mut link = Self { + id: Auto::auto(), + user_id, + issuer: LimitedString::new(issuer).unwrap(), + sub: LimitedString::new(sub).unwrap(), + email: email.map(str::to_owned), + name: name.map(str::to_owned), + avatar_url: None, + }; + link.insert(db).await?; + Ok(link) + } + + /// Update cached claims (email, name) on an existing link. + pub async fn update_claims( + &mut self, + db: &Database, + email: Option<&str>, + name: Option<&str>, + ) -> cot::db::Result<()> { + self.email = email.map(str::to_owned); + self.name = name.map(str::to_owned); + self.save(db).await + } + + /// Delete this OIDC link by primary key. + pub async fn delete(self, db: &Database) -> cot::db::Result<()> { + let link_id = self.id; + cot::db::query!(OidcLink, $id == link_id).delete(db).await?; + Ok(()) + } + + /// Accessor for the linked user ID. + pub fn user_id(&self) -> i64 { + self.user_id + } +} + +// --------------------------------------------------------------------------- +// Migrations +// --------------------------------------------------------------------------- + +pub mod db_migrations { + use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; + use cot::db::{DatabaseField, Identifier, LimitedString}; + + // -- M0002: create amnezia_fellow__user -------------------------------- + + #[derive(Debug, Copy, Clone)] + pub struct M0002CreateUser; + + impl migrations::Migration for M0002CreateUser { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0002_create_user"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0001_create_config_entry", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("amnezia_fellow__user")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("username"), + as DatabaseField>::TYPE, + ) + .unique(), + Field::new(Identifier::new("password"), ::TYPE) + .set_null(true), + Field::new(Identifier::new("email"), ::TYPE) + .set_null(true), + Field::new( + Identifier::new("display_name"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("avatar_url"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("role"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("is_active"), ::TYPE), + ]) + .build()]; + } + + // -- M0003: create amnezia_fellow__oidc_link --------------------------- + + #[derive(Debug, Copy, Clone)] + pub struct M0003CreateOidcLink; + + impl migrations::Migration for M0003CreateOidcLink { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0003_create_oidc_link"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0002_create_user", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("amnezia_fellow__oidc_link")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new( + Identifier::new("issuer"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("sub"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("email"), ::TYPE) + .set_null(true), + Field::new(Identifier::new("name"), ::TYPE).set_null(true), + Field::new( + Identifier::new("avatar_url"), + ::TYPE, + ) + .set_null(true), + ]) + .build()]; + } + + // -- M0004: indexes on amnezia_fellow__oidc_link ----------------------- + + #[cot::db::migrations::migration_op] + async fn create_oidc_link_indexes( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE UNIQUE INDEX idx_amnezia_fellow_oidc_link_issuer_sub \ + ON amnezia_fellow__oidc_link (issuer, sub)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX idx_amnezia_fellow_oidc_link_user_id \ + ON amnezia_fellow__oidc_link (user_id)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0004OidcLinkIndexes; + + impl migrations::Migration for M0004OidcLinkIndexes { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0004_oidc_link_indexes"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0003_create_oidc_link", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_oidc_link_indexes).build()]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[ + &M0002CreateUser, + &M0003CreateOidcLink, + &M0004OidcLinkIndexes, + ]; +} diff --git a/src/vpn.rs b/src/vpn.rs new file mode 100644 index 0000000..af31a81 --- /dev/null +++ b/src/vpn.rs @@ -0,0 +1,1090 @@ +use std::collections::{BTreeMap, HashSet}; +use std::net::Ipv4Addr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; +use cot::db::{Auto, Database, DatabaseField, Identifier, LimitedString, Model}; +use curve25519_dalek::montgomery::MontgomeryPoint; +use k8s_openapi::api::core::v1::{Pod, Secret}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; +use kube::api::{ListParams, PostParams}; +use kube::{Api, Client}; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::auth::{AuthenticatedUser, Role}; +use crate::config::AppConfig; + +// --------------------------------------------------------------------------- +// VpnClient model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct VpnClient { + #[model(primary_key)] + id: Auto, + owner_user_id: i64, + name: LimitedString<255>, + address: LimitedString<64>, + public_key: LimitedString<128>, + private_key: LimitedString<128>, + enabled: bool, + created_at: LimitedString<64>, + updated_at: LimitedString<64>, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct VpnClientView { + pub id: i64, + pub owner_user_id: i64, + pub owner_username: String, + pub owner_display_name: String, + pub name: String, + pub address: String, + pub public_key: String, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +impl VpnClient { + pub async fn list_all(db: &Database) -> cot::db::Result> { + Self::objects().all(db).await + } + + pub async fn list_visible( + db: &Database, + user: &AuthenticatedUser, + ) -> cot::db::Result> { + if user.role == Role::Admin { + Self::list_all(db).await + } else { + let owner_user_id = user.id; + cot::db::query!(VpnClient, $owner_user_id == owner_user_id) + .all(db) + .await + } + } + + pub async fn get_visible( + db: &Database, + user: &AuthenticatedUser, + client_id: i64, + ) -> cot::db::Result> { + let Some(client) = Self::get_by_primary_key(db, Auto::Fixed(client_id)).await? else { + return Ok(None); + }; + if user.role == Role::Admin || client.owner_user_id == user.id { + Ok(Some(client)) + } else { + Ok(None) + } + } + + pub async fn create_for_owner( + db: &Database, + owner_user_id: i64, + name: &str, + cidr: &str, + ) -> cot::db::Result { + let keypair = generate_keypair().map_err(db_custom_error)?; + let address = next_available_address(db, cidr) + .await + .map_err(db_custom_error)?; + let now = now_timestamp(); + let name = if name.trim().is_empty() { + "Amnezia client" + } else { + name.trim() + }; + let mut client = Self { + id: Auto::auto(), + owner_user_id, + name: LimitedString::new(name).unwrap(), + address: LimitedString::new(address.as_str()).unwrap(), + public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(), + private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(), + enabled: true, + created_at: LimitedString::new(now.as_str()).unwrap(), + updated_at: LimitedString::new(now.as_str()).unwrap(), + }; + client.insert(db).await?; + Ok(client) + } + + pub async fn set_enabled(&mut self, db: &Database, enabled: bool) -> cot::db::Result<()> { + self.enabled = enabled; + let now = now_timestamp(); + self.updated_at = LimitedString::new(now.as_str()).unwrap(); + self.save(db).await + } + + pub async fn delete_by_id(db: &Database, client_id: i64) -> cot::db::Result<()> { + cot::db::query!(VpnClient, $id == Auto::Fixed(client_id)) + .delete(db) + .await?; + Ok(()) + } + + pub fn view(&self) -> VpnClientView { + VpnClientView { + id: self.id_val(), + owner_user_id: self.owner_user_id, + owner_username: String::new(), + owner_display_name: String::new(), + name: self.name.to_string(), + address: self.address.to_string(), + public_key: self.public_key.to_string(), + enabled: self.enabled, + created_at: self.created_at.to_string(), + updated_at: self.updated_at.to_string(), + } + } + + pub fn id_val(&self) -> i64 { + self.id.unwrap() + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn owner_user_id(&self) -> i64 { + self.owner_user_id + } + + pub fn name_str(&self) -> &str { + &self.name + } + + pub fn address_str(&self) -> &str { + &self.address + } + + pub fn public_key_str(&self) -> &str { + &self.public_key + } + + pub fn private_key_str(&self) -> &str { + &self.private_key + } +} + +// --------------------------------------------------------------------------- +// WireGuard/AWG config rendering +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct Keypair { + private_key: String, + public_key: String, +} + +fn generate_keypair() -> Result { + let mut private_key = [0u8; 32]; + getrandom::fill(&mut private_key).map_err(|e| format!("key generation failed: {e}"))?; + private_key[0] &= 248; + private_key[31] &= 127; + private_key[31] |= 64; + + let public_key = MontgomeryPoint::mul_base_clamped(private_key).to_bytes(); + let engine = base64::engine::general_purpose::STANDARD; + Ok(Keypair { + private_key: engine.encode(private_key), + public_key: engine.encode(public_key), + }) +} + +pub fn render_peer_secret(clients: &[VpnClient]) -> String { + let mut out = String::from("# Generated by amnezia-fellow. Do not edit manually.\n"); + for client in clients.iter().filter(|client| client.enabled()) { + out.push('\n'); + out.push_str("[Peer]\n"); + out.push_str(&format!( + "# id={} owner={} name={}\n", + client.id_val(), + client.owner_user_id(), + client.name_str() + )); + out.push_str(&format!("PublicKey = {}\n", client.public_key_str())); + out.push_str(&format!("AllowedIPs = {}/32\n", client.address_str())); + } + out +} + +pub fn render_client_config( + client: &VpnClient, + server_public_key: &str, + endpoint: &str, + config: &AppConfig, +) -> String { + format!( + "[Interface]\n\ + PrivateKey = {}\n\ + Address = {}/32\n\ + DNS = {}\n\ + MTU = {}\n\ + Jc = 4\n\ + Jmin = 64\n\ + Jmax = 128\n\ + S1 = 15\n\ + S2 = 18\n\ + S3 = 20\n\ + S4 = 23\n\ + H1 = 1020325451\n\ + H2 = 3288052141\n\ + H3 = 1766607858\n\ + H4 = 2528465083\n\ + I1 = \n\ + \n\ + [Peer]\n\ + PublicKey = {}\n\ + AllowedIPs = 0.0.0.0/0, ::/0\n\ + Endpoint = {}\n\ + PersistentKeepalive = 25\n", + client.private_key_str(), + client.address_str(), + config.vpn_dns, + config.vpn_mtu, + server_public_key, + endpoint, + ) +} + +pub fn render_vpn_url(payload: &str) -> String { + format!("vpn://{payload}") +} + +pub fn render_vpn_payload( + client: &VpnClient, + server_public_key: &str, + endpoint_name: &str, + endpoint: &str, + config: &AppConfig, +) -> Result { + let endpoint = parse_endpoint(endpoint)?; + let client_ip = format!("{}/32", client.address_str()); + let rendered_config = + render_client_config(client, server_public_key, &endpoint.raw_endpoint, config); + let (dns1, dns2) = dns_pair(&config.vpn_dns); + + let last_config = serde_json::json!({ + "config": rendered_config, + "hostName": &endpoint.host, + "port": endpoint.port, + "client_priv_key": client.private_key_str(), + "client_ip": client_ip, + "server_pub_key": server_public_key, + "mtu": config.vpn_mtu.to_string(), + "persistent_keep_alive": "25", + "allowed_ips": ["0.0.0.0/0", "::/0"], + "Jc": "4", + "Jmin": "64", + "Jmax": "128", + "S1": "15", + "S2": "18", + "S3": "20", + "S4": "23", + "H1": "1020325451", + "H2": "3288052141", + "H3": "1766607858", + "H4": "2528465083", + "I1": "", + }); + + let server = serde_json::json!({ + "containers": [ + { + "container": "amnezia-awg", + "awg": { + "last_config": serde_json::to_string(&last_config) + .map_err(|e| format!("failed to render Amnezia last_config: {e}"))?, + "isThirdPartyConfig": true, + "port": endpoint.port, + "transport_proto": "udp", + "protocol_version": "2", + } + } + ], + "defaultContainer": "amnezia-awg", + "description": vpn_url_description(endpoint_name, client.name_str()), + "dns1": dns1, + "dns2": dns2, + "hostName": &endpoint.host, + }); + + let json = serde_json::to_vec(&server) + .map_err(|e| format!("failed to render Amnezia vpn URL JSON: {e}"))?; + let compressed = qcompress(&json)?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(compressed)) +} + +fn vpn_url_description(endpoint_name: &str, fallback: &str) -> String { + let name = endpoint_name.trim(); + if name.is_empty() { + fallback.trim().to_owned() + } else { + name.to_owned() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EndpointParts { + host: String, + port: u16, + raw_endpoint: String, +} + +fn parse_endpoint(endpoint: &str) -> Result { + let raw_endpoint = endpoint.trim(); + if raw_endpoint.is_empty() { + return Err("VPN endpoint is empty".to_owned()); + } + + let (host, port) = if let Some(rest) = raw_endpoint.strip_prefix('[') { + let Some((host, rest)) = rest.split_once(']') else { + return Err(format!("invalid bracketed VPN endpoint {raw_endpoint:?}")); + }; + let Some(port) = rest.strip_prefix(':') else { + return Err(format!("VPN endpoint {raw_endpoint:?} has no port")); + }; + (host, port) + } else { + raw_endpoint + .rsplit_once(':') + .ok_or_else(|| format!("VPN endpoint {raw_endpoint:?} has no port"))? + }; + + if host.trim().is_empty() { + return Err(format!("VPN endpoint {raw_endpoint:?} has an empty host")); + } + + let port = port + .parse::() + .map_err(|e| format!("invalid VPN endpoint port {port:?}: {e}"))?; + Ok(EndpointParts { + host: host.to_owned(), + port, + raw_endpoint: raw_endpoint.to_owned(), + }) +} + +fn dns_pair(dns: &str) -> (String, String) { + let mut parts = dns + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()); + ( + parts.next().unwrap_or("1.1.1.1").to_owned(), + parts.next().unwrap_or("").to_owned(), + ) +} + +fn qcompress(data: &[u8]) -> Result, String> { + let length = + u32::try_from(data.len()).map_err(|_| "Amnezia vpn URL payload is too large".to_owned())?; + let compressed = miniz_oxide::deflate::compress_to_vec_zlib(data, 8); + let mut out = Vec::with_capacity(4 + compressed.len()); + out.extend_from_slice(&length.to_be_bytes()); + out.extend_from_slice(&compressed); + Ok(out) +} + +// --------------------------------------------------------------------------- +// Kubernetes Secret sync +// --------------------------------------------------------------------------- + +const CLIENT_SECRET_UPDATED_AT_ANNOTATION: &str = + "amnezia-fellow.hexor.cy/client-secret-updated-at-ms"; +const AMNEZIAWG_POD_LABEL_SELECTOR: &str = "app=amneziawg"; + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SecretSyncResult { + pub changed: bool, + pub message: String, +} + +pub async fn sync_clients_secret( + config: &AppConfig, + rendered_peers: String, +) -> Result { + let client = Client::try_default() + .await + .map_err(|e| format!("failed to create Kubernetes client: {e}"))?; + let api: Api = Api::namespaced(client, &config.k8s_namespace); + + let desired = rendered_peers.into_bytes(); + let name = &config.k8s_clients_secret; + let key = &config.k8s_clients_secret_key; + + match api.get_opt(name).await { + Ok(Some(mut secret)) => { + let mut data = secret.data.take().unwrap_or_default(); + if data.get(key).map(|value| value.0.as_slice()) == Some(desired.as_slice()) { + return Ok(SecretSyncResult { + changed: false, + message: "client Secret is already up to date".to_owned(), + }); + } + + data.insert(key.clone(), k8s_openapi::ByteString(desired)); + secret.data = Some(data); + mark_client_secret_updated(&mut secret); + api.replace(name, &PostParams::default(), &secret) + .await + .map_err(|e| format!("failed to update client Secret: {e}"))?; + Ok(SecretSyncResult { + changed: true, + message: "client Secret updated".to_owned(), + }) + } + Ok(None) => { + let mut data = BTreeMap::new(); + data.insert(key.clone(), k8s_openapi::ByteString(desired)); + let secret = Secret { + metadata: ObjectMeta { + name: Some(name.clone()), + annotations: Some(BTreeMap::from([( + CLIENT_SECRET_UPDATED_AT_ANNOTATION.to_owned(), + now_millis().to_string(), + )])), + ..ObjectMeta::default() + }, + data: Some(data), + type_: Some("Opaque".to_owned()), + ..Secret::default() + }; + api.create(&PostParams::default(), &secret) + .await + .map_err(|e| format!("failed to create client Secret: {e}"))?; + Ok(SecretSyncResult { + changed: true, + message: "client Secret created".to_owned(), + }) + } + Err(e) => Err(format!("failed to read client Secret: {e}")), + } +} + +pub async fn sync_from_database( + db: &Database, + config: &AppConfig, +) -> Result { + let clients = VpnClient::list_all(db) + .await + .map_err(|e| format!("failed to list VPN clients: {e}"))?; + sync_clients_secret(config, render_peer_secret(&clients)).await +} + +fn mark_client_secret_updated(secret: &mut Secret) { + secret + .metadata + .annotations + .get_or_insert_with(BTreeMap::new) + .insert( + CLIENT_SECRET_UPDATED_AT_ANNOTATION.to_owned(), + now_millis().to_string(), + ); +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct K8sVpnEndpoint { + pub name: String, + pub display_name: String, + pub endpoint: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct K8sVpnRuntime { + pub server_public_key: String, + pub endpoints: Vec, +} + +pub async fn read_runtime_from_kubernetes(config: &AppConfig) -> Result { + let client = Client::try_default() + .await + .map_err(|e| format!("failed to create Kubernetes client: {e}"))?; + let secrets: Api = Api::namespaced(client, &config.k8s_namespace); + + let server_secret = secrets + .get(&config.k8s_server_secret) + .await + .map_err(|e| format!("failed to read server Secret: {e}"))?; + let server_public_key = secret_text(&server_secret, "server-public-key") + .ok_or_else(|| "server Secret does not contain server-public-key".to_owned())?; + + let endpoints_secret = secrets + .get(&config.k8s_endpoints_secret) + .await + .map_err(|e| format!("failed to read endpoints Secret: {e}"))?; + let name_overrides = endpoint_name_overrides(config); + let mut endpoints = endpoints_secret + .data + .unwrap_or_default() + .into_iter() + .filter_map(|(name, value)| { + let endpoint = String::from_utf8(value.0).ok()?; + let endpoint = endpoint.trim(); + if endpoint.is_empty() { + return None; + } + let display_name = name_overrides + .get(&name) + .filter(|value| !value.trim().is_empty()) + .cloned() + .unwrap_or_else(|| name.clone()); + Some(K8sVpnEndpoint { + name, + display_name, + endpoint: endpoint.to_owned(), + }) + }) + .collect::>(); + endpoints.sort_by(|left, right| { + left.display_name + .cmp(&right.display_name) + .then_with(|| left.name.cmp(&right.name)) + }); + + Ok(K8sVpnRuntime { + server_public_key, + endpoints, + }) +} + +pub fn disabled_endpoint_names(config: &AppConfig) -> HashSet { + config + .vpn_disabled_endpoints + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .collect() +} + +pub fn endpoint_name_overrides(config: &AppConfig) -> BTreeMap { + let raw = config.vpn_endpoint_name_overrides.trim(); + if raw.is_empty() { + return BTreeMap::new(); + } + + match serde_json::from_str::>(raw) { + Ok(map) => map + .into_iter() + .map(|(name, display_name)| (name.trim().to_owned(), display_name.trim().to_owned())) + .filter(|(name, display_name)| !name.is_empty() && !display_name.is_empty()) + .collect(), + Err(e) => { + tracing::warn!("ignoring invalid vpn_endpoint_name_overrides JSON: {e}"); + BTreeMap::new() + } + } +} + +pub fn filter_enabled_endpoints( + endpoints: Vec, + config: &AppConfig, +) -> Vec { + let disabled = disabled_endpoint_names(config); + endpoints + .into_iter() + .filter(|endpoint| !disabled.contains(&endpoint.name)) + .collect() +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct VpnRolloutStatus { + pub namespace: String, + pub clients_secret: String, + pub config_updated_at_ms: Option, + pub pods: Vec, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct VpnPodRolloutStatus { + pub name: String, + pub node_name: String, + pub endpoint_name: Option, + pub endpoint: Option, + pub phase: String, + pub ready: bool, + pub restart_count: i32, + pub started_at_ms: Option, + pub uptime_seconds: Option, + pub rollout_status: String, + pub message: Option, +} + +pub async fn read_rollout_status_from_kubernetes( + config: &AppConfig, +) -> Result { + let client = Client::try_default() + .await + .map_err(|e| format!("failed to create Kubernetes client: {e}"))?; + let secrets: Api = Api::namespaced(client.clone(), &config.k8s_namespace); + let pods: Api = Api::namespaced(client, &config.k8s_namespace); + + let clients_secret = secrets + .get_opt(&config.k8s_clients_secret) + .await + .map_err(|e| format!("failed to read client Secret: {e}"))?; + let config_updated_at_ms = clients_secret + .as_ref() + .and_then(client_secret_updated_at_ms) + .or_else(|| { + clients_secret + .as_ref() + .and_then(|secret| secret.metadata.creation_timestamp.as_ref()) + .map(time_to_millis) + }); + + let endpoints = read_endpoint_map(&secrets, &config.k8s_endpoints_secret).await?; + let name_overrides = endpoint_name_overrides(config); + let now_ms = now_millis(); + let mut pod_statuses = pods + .list(&ListParams::default().labels(AMNEZIAWG_POD_LABEL_SELECTOR)) + .await + .map_err(|e| format!("failed to list AmneziaWG pods: {e}"))? + .items + .into_iter() + .map(|pod| { + pod_rollout_status( + pod, + &endpoints, + &name_overrides, + config_updated_at_ms, + now_ms, + ) + }) + .collect::>(); + pod_statuses.sort_by(|left, right| { + left.endpoint_name + .cmp(&right.endpoint_name) + .then_with(|| left.name.cmp(&right.name)) + }); + + Ok(VpnRolloutStatus { + namespace: config.k8s_namespace.clone(), + clients_secret: config.k8s_clients_secret.clone(), + config_updated_at_ms, + pods: pod_statuses, + }) +} + +async fn read_endpoint_map( + secrets: &Api, + endpoints_secret_name: &str, +) -> Result, String> { + let endpoints_secret = secrets + .get(endpoints_secret_name) + .await + .map_err(|e| format!("failed to read endpoints Secret: {e}"))?; + Ok(endpoints_secret + .data + .unwrap_or_default() + .into_iter() + .filter_map(|(name, value)| { + let endpoint = String::from_utf8(value.0).ok()?; + let endpoint = endpoint.trim(); + if endpoint.is_empty() { + None + } else { + Some((name, endpoint.to_owned())) + } + }) + .collect()) +} + +fn pod_rollout_status( + pod: Pod, + endpoints: &BTreeMap, + name_overrides: &BTreeMap, + config_updated_at_ms: Option, + now_ms: i64, +) -> VpnPodRolloutStatus { + let name = pod.metadata.name.unwrap_or_default(); + let node_name = pod + .spec + .as_ref() + .and_then(|spec| spec.node_name.clone()) + .unwrap_or_default(); + let endpoint = endpoints.get(&node_name).cloned(); + let endpoint_name = endpoint.as_ref().map(|_| { + name_overrides + .get(&node_name) + .cloned() + .unwrap_or_else(|| node_name.clone()) + }); + + let status = pod.status.as_ref(); + let phase = status + .and_then(|status| status.phase.clone()) + .unwrap_or_else(|| "Unknown".to_owned()); + let ready = status.is_some_and(pod_ready); + let restart_count = status.map_or(0, amneziawg_restart_count); + let started_at_ms = status + .and_then(|status| status.start_time.as_ref()) + .map(time_to_millis); + let uptime_seconds = started_at_ms.map(|started| { + let elapsed_ms = now_ms.saturating_sub(started); + (elapsed_ms / 1000) as u64 + }); + let rollout_status = rollout_status(config_updated_at_ms, started_at_ms, ready); + let message = status.and_then(|status| status.message.clone().or(status.reason.clone())); + + VpnPodRolloutStatus { + name, + node_name, + endpoint_name, + endpoint, + phase, + ready, + restart_count, + started_at_ms, + uptime_seconds, + rollout_status, + message, + } +} + +fn pod_ready(status: &k8s_openapi::api::core::v1::PodStatus) -> bool { + status + .conditions + .as_ref() + .and_then(|conditions| { + conditions + .iter() + .find(|condition| condition.type_ == "Ready") + }) + .is_some_and(|condition| condition.status == "True") +} + +fn amneziawg_restart_count(status: &k8s_openapi::api::core::v1::PodStatus) -> i32 { + let Some(container_statuses) = status.container_statuses.as_ref() else { + return 0; + }; + container_statuses + .iter() + .find(|container| container.name == "amneziawg") + .map(|container| container.restart_count) + .unwrap_or_else(|| { + container_statuses + .iter() + .map(|container| container.restart_count) + .sum() + }) +} + +fn rollout_status( + config_updated_at_ms: Option, + started_at_ms: Option, + ready: bool, +) -> String { + match (config_updated_at_ms, started_at_ms) { + (None, _) => "unknown".to_owned(), + (Some(_), None) => "pending_restart".to_owned(), + (Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(), + (Some(updated), Some(started)) if started >= updated => "starting".to_owned(), + (Some(_), Some(_)) => "pending_restart".to_owned(), + } +} + +fn client_secret_updated_at_ms(secret: &Secret) -> Option { + secret + .metadata + .annotations + .as_ref()? + .get(CLIENT_SECRET_UPDATED_AT_ANNOTATION)? + .parse() + .ok() +} + +fn time_to_millis(time: &Time) -> i64 { + time.0.as_millisecond() +} + +fn secret_text(secret: &Secret, key: &str) -> Option { + let bytes = secret.data.as_ref()?.get(key)?.0.clone(); + String::from_utf8(bytes) + .ok() + .map(|value| value.trim().to_owned()) +} + +// --------------------------------------------------------------------------- +// Address allocation +// --------------------------------------------------------------------------- + +async fn next_available_address(db: &Database, cidr: &str) -> Result { + let (network, prefix) = parse_ipv4_cidr(cidr)?; + let used = VpnClient::list_all(db) + .await + .map_err(|e| format!("failed to read used addresses: {e}"))? + .into_iter() + .filter_map(|client| client.address_str().parse::().ok()) + .map(ipv4_to_u32) + .collect::>(); + + let network_u32 = ipv4_to_u32(network); + let host_count = 1u64 << (32 - prefix); + if host_count < 4 { + return Err(format!("CIDR {cidr} is too small for client allocation")); + } + + let first_client = network_u32 + 2; + let last_client = network_u32 + host_count as u32 - 2; + for candidate in first_client..=last_client { + if !used.contains(&candidate) { + return Ok(u32_to_ipv4(candidate).to_string()); + } + } + + Err(format!("CIDR {cidr} has no free client addresses")) +} + +fn parse_ipv4_cidr(cidr: &str) -> Result<(Ipv4Addr, u32), String> { + let Some((ip, prefix)) = cidr.split_once('/') else { + return Err(format!("invalid CIDR {cidr:?}")); + }; + let ip = ip + .parse::() + .map_err(|e| format!("invalid IPv4 CIDR address {ip:?}: {e}"))?; + let prefix = prefix + .parse::() + .map_err(|e| format!("invalid IPv4 CIDR prefix {prefix:?}: {e}"))?; + if prefix > 30 { + return Err(format!("CIDR prefix /{prefix} is too small")); + } + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Ok((u32_to_ipv4(ipv4_to_u32(ip) & mask), prefix)) +} + +fn ipv4_to_u32(ip: Ipv4Addr) -> u32 { + u32::from_be_bytes(ip.octets()) +} + +fn u32_to_ipv4(value: u32) -> Ipv4Addr { + Ipv4Addr::from(value.to_be_bytes()) +} + +fn now_timestamp() -> String { + (now_millis() / 1000).to_string() +} + +fn now_millis() -> i64 { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + i64::try_from(seconds).unwrap_or(i64::MAX) +} + +fn db_custom_error(message: String) -> cot::db::DatabaseError { + cot::db::DatabaseError::value_decode(std::io::Error::other(message)) +} + +// --------------------------------------------------------------------------- +// Migrations +// --------------------------------------------------------------------------- + +pub mod db_migrations { + use super::*; + + #[derive(Debug, Copy, Clone)] + pub struct M0005CreateVpnClient; + + impl migrations::Migration for M0005CreateVpnClient { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0005_create_vpn_client"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0004_oidc_link_indexes", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("amnezia_fellow__vpn_client")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("owner_user_id"), + ::TYPE, + ), + Field::new( + Identifier::new("name"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("address"), + as DatabaseField>::TYPE, + ) + .unique(), + Field::new( + Identifier::new("public_key"), + as DatabaseField>::TYPE, + ) + .unique(), + Field::new( + Identifier::new("private_key"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("enabled"), ::TYPE), + Field::new( + Identifier::new("created_at"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("updated_at"), + as DatabaseField>::TYPE, + ), + ]) + .build()]; + } + + #[cot::db::migrations::migration_op] + async fn create_vpn_client_indexes( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE INDEX idx_amnezia_fellow_vpn_client_owner \ + ON amnezia_fellow__vpn_client (owner_user_id)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0006VpnClientIndexes; + + impl migrations::Migration for M0006VpnClientIndexes { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0006_vpn_client_indexes"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0005_create_vpn_client", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_vpn_client_indexes).build()]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0005CreateVpnClient, &M0006VpnClientIndexes]; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cidr_parser_normalizes_network() { + let (network, prefix) = parse_ipv4_cidr("10.8.42.7/16").unwrap(); + assert_eq!(network.to_string(), "10.8.0.0"); + assert_eq!(prefix, 16); + } + + #[test] + fn generated_keypair_has_wireguard_shape() { + let keypair = generate_keypair().unwrap(); + assert_eq!(keypair.private_key.len(), 44); + assert_eq!(keypair.public_key.len(), 44); + } + + #[test] + fn endpoint_parser_accepts_ipv4_and_bracketed_ipv6() { + assert_eq!( + parse_endpoint("203.0.113.4:5847").unwrap(), + EndpointParts { + host: "203.0.113.4".to_owned(), + port: 5847, + raw_endpoint: "203.0.113.4:5847".to_owned(), + } + ); + assert_eq!( + parse_endpoint("[2001:db8::4]:5847").unwrap(), + EndpointParts { + host: "2001:db8::4".to_owned(), + port: 5847, + raw_endpoint: "[2001:db8::4]:5847".to_owned(), + } + ); + } + + #[test] + fn vpn_url_description_prefers_endpoint_secret_key() { + assert_eq!(vpn_url_description("de-fsn1", "phone"), "de-fsn1"); + assert_eq!(vpn_url_description(" ", "phone"), "phone"); + } + + #[test] + fn endpoint_name_overrides_parse_json_map() { + let config = AppConfig { + vpn_endpoint_name_overrides: r#"{"spb.tail2fe2d.ts.net":" SPB ","empty":" "}"#.into(), + ..AppConfig::default() + }; + let overrides = endpoint_name_overrides(&config); + assert_eq!( + overrides.get("spb.tail2fe2d.ts.net").map(String::as_str), + Some("SPB") + ); + assert!(!overrides.contains_key("empty")); + } + + #[test] + fn qcompress_uses_qt_wire_format() { + let payload = br#"{"description":"de-fsn1"}"#; + let compressed = qcompress(payload).unwrap(); + assert_eq!( + u32::from_be_bytes(compressed[0..4].try_into().unwrap()), + payload.len() as u32 + ); + assert_eq!( + miniz_oxide::inflate::decompress_to_vec_zlib(&compressed[4..]).unwrap(), + payload + ); + } + + #[test] + fn vpn_qr_payload_is_base64url_compressed_json_without_scheme() { + let client = VpnClient { + id: Auto::Fixed(7), + owner_user_id: 1, + name: LimitedString::new("phone").unwrap(), + address: LimitedString::new("10.8.0.2").unwrap(), + public_key: LimitedString::new("client-public-key").unwrap(), + private_key: LimitedString::new("client-private-key").unwrap(), + enabled: true, + created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), + updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), + }; + let config = AppConfig::default(); + + let payload = render_vpn_payload( + &client, + "server-public-key", + "de-fsn1", + "203.0.113.10:5847", + &config, + ) + .unwrap(); + assert!(!payload.starts_with("vpn://")); + assert_eq!(render_vpn_url(&payload), format!("vpn://{payload}")); + + let compressed = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .unwrap(); + let json = miniz_oxide::inflate::decompress_to_vec_zlib(&compressed[4..]).unwrap(); + let value: serde_json::Value = serde_json::from_slice(&json).unwrap(); + + assert_eq!(value["description"], "de-fsn1"); + assert_eq!(value["hostName"], "203.0.113.10"); + assert_eq!(value["containers"][0]["container"], "amnezia-awg"); + } +} diff --git a/templates/admin/app.html b/templates/admin/app.html new file mode 100644 index 0000000..c840cca --- /dev/null +++ b/templates/admin/app.html @@ -0,0 +1,509 @@ +{% extends "base.html" %} + +{% block title %}{{ t.nav_admin }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + + +{% endblock head_extra %} + +{% block body %} +
+ + +
+
+ v{{ app_version }} + +
+ EN + RU +
+ {{ t.nav_logout }} +
+ +
+
+

+
+ + + + +
+
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+
+
+ + +
+ + +{% endblock body %} diff --git a/templates/admin/setup.html b/templates/admin/setup.html new file mode 100644 index 0000000..c19d9ef --- /dev/null +++ b/templates/admin/setup.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}{{ t.setup_heading }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block body %} +
+

{{ t.setup_heading }}

+ + {% if !message.is_empty() %} +
{{ message }}
+ {% endif %} + +
+ + + + + + + +
+
+{% endblock body %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5028260 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,14 @@ + + + + + + {% block title %}{{ t.site_name }}{% endblock title %} + {% block head_extra %}{% endblock head_extra %} + + +{% block body %} + {% block content %}{% endblock content %} +{% endblock body %} + + diff --git a/templates/configs.html b/templates/configs.html new file mode 100644 index 0000000..53b5381 --- /dev/null +++ b/templates/configs.html @@ -0,0 +1,590 @@ +{% extends "base.html" %} + +{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + + +{% endblock head_extra %} + +{% block body %} +
+ + +
+
+ v{{ app_version }} + +
+ EN + RU +
+ {{ t.nav_logout }} +
+ +
+
+

{{ t.configs_heading }}

+
+ + + {% if is_admin %} + + {% endif %} +
+
+ +
+ +
+
+

{{ t.configs_rollout_heading }}

+ +
+ + + + + +
+ + + + +
+
+ + + + +
+ + +{% endblock body %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..50ed686 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block body %} + +{% endblock body %}