diff --git a/Cargo.lock b/Cargo.lock index 696a646..efe035e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "argh" version = "0.1.19" @@ -33,12 +39,136 @@ dependencies = [ "serde", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -56,6 +186,21 @@ name = "camino" version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] [[package]] name = "cfg-if" @@ -63,10 +208,112 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "display_full_error" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7c47e9a2fd28a8edd1446f10dabe8ac5d26bb77ed2b1077bfcd8308904e8c6" + [[package]] name = "dn_escape" version = "0.1.0" +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[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-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -121,6 +368,123 @@ dependencies = [ "r-efi", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[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 = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -154,7 +518,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "ldap3_lber", "nom", @@ -176,13 +540,24 @@ name = "llldap" version = "0.1.0" dependencies = [ "argh", + "async-trait", + "axum", + "axum-extra", + "base64 0.23.1", "camino", "dn_escape", "futures-util", + "http", "ldap3_proto", + "minijinja", + "minijinja-embed", "serde", + "serde_json", + "static-serve", "tokio", "tokio-util", + "tower", + "tower-http", "tracing", "tracing-subscriber", "uuid", @@ -194,18 +569,72 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memo-map" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5449c8c750f1a07ea702bbd212bd999fceece9b3d1508b17023b3e174583124b" + +[[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 = "minijinja" +version = "2.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9" +dependencies = [ + "memo-map", + "serde", +] + +[[package]] +name = "minijinja-embed" +version = "2.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2795289c499a1331f73166ff8b8e0e68fdc81ef0b385eecb0e4de96f1638b66" + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -236,6 +665,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -269,12 +704,30 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -299,12 +752,30 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "range-requests" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e30cea6780132baa8257e81ab3f6e11526a9271e9032963eab8d830972e68b6" +dependencies = [ + "axum-core", + "bytes", + "http", + "thiserror", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -335,6 +806,53 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +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_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 = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -344,6 +862,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -366,6 +896,36 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "static-serve" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c7a4b3dbe44375f7e30895c04d3ca4fde020eb7336c0f6a71ed495c5302e1a" +dependencies = [ + "axum", + "bytes", + "range-requests", + "static-serve-macro", +] + +[[package]] +name = "static-serve-macro" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb887d817cf39e80fd7fdd21c4f138c8277d7dc2623fee4d07e141f4ce32a0a" +dependencies = [ + "display_full_error", + "flate2", + "glob", + "mime_guess", + "proc-macro2", + "quote", + "sha2", + "syn 3.0.4", + "thiserror", + "zstd", +] + [[package]] name = "syn" version = "2.0.119" @@ -388,6 +948,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "thiserror" version = "2.0.20" @@ -417,12 +983,43 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -456,12 +1053,66 @@ dependencies = [ "tokio", ] +[[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", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[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 = "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", @@ -513,6 +1164,18 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -536,6 +1199,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -601,3 +1270,43 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 0ea53c5..827b2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,24 @@ edition = "2024" [dependencies] argh = "0.1.19" -camino = "1.2.5" +async-trait = "0.1.92" +axum = { version = "0.8.9", optional = true, features = ["macros"] } +axum-extra = { version = "0.12.6", features = ["cookie"], optional = true } +base64 = "0.23.1" +camino = { version = "1.2.5", features = ["serde1"] } dn_escape = { path = "vendor/dn_escape" } futures-util = { version = "0.3.34", features = ["sink"] } +http = { version = "1.5.0", optional = true } ldap3_proto = "0.8.1" +minijinja = { version = "2.24.0", optional = true } +minijinja-embed = { version = "2.24.0", optional = true } serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +static-serve = { version = "0.6.3", optional = true } tokio = { version = "1.53.1", features = ["macros", "net", "rt", "time", "sync"] } tokio-util = { version = "0.7.19", features = ["codec"] } +tower = "0.5.3" +tower-http = { version = "0.7.1", features = ["fs"], optional = true } tracing = "0.1.44" tracing-subscriber = "0.3.23" uuid = { version = "1.26.0", features = ["v4"] } @@ -22,3 +33,14 @@ complexity = "deny" pedantic = "deny" perf = "deny" style = "deny" + +[features] +default = [ "http", "noembed" ] +# Embed assets and templates in binary +embed = [ "dep:minijinja-embed", "dep:static-serve" ] +# Don't embed assets and templates in binary +noembed = [ "dep:tower-http" ] +http = ["dep:axum", "dep:axum-extra", "dep:http", "dep:minijinja"] + +[build-dependencies] +minijinja-embed = { version = "2.24.0", optional = true } diff --git a/README.md b/README.md index bf3fa61..d28b9ff 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Compared to lldap, llldap: - does not use GraphQL to build an API, and therefore does not require custom logic in the client (JS/WASM) - supports virtualhosts out-of-the-box - supports mailalias with multiple values in search queries +- support listening on unix domain sockets (UDS) out-of-the-box ## Potential future features diff --git a/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..5660244 --- /dev/null +++ b/assets/css/main.css @@ -0,0 +1,81 @@ +body { + margin: 0; +} + +#logo { + display: block; + max-height: 5rem; + max-width: 5rem; + height: auto; + width: auto; + margin: 0 auto; +} + +#center { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: space-around; + flex-direction: column; + max-width: 250px; + margin: auto; +} + +#login-form { + max-width: 200px; + margin: 0 auto; +} + +#login-line { + margin-top: 1rem; + display: flex; + justify-content: center; + font-size: 2rem; + text-align: center; + height: 2.5rem; + gap: 0.5rem; +} + +#login-line > img { + width: 1.75rem; + height: auto; +} + +#login-line > input { + border-color: rgba(31, 41, 55, 0.2); + border-radius: 8px; + padding: 0.5rem 1rem; +} + +#submit-login { + width: 100%; + background-color: rgb(87, 13, 248); + cursor: pointer; + box-sizing: border-box; + border: none; + color: rgb(240, 240, 240); + font-weight: bolder; + text-transform: uppercase; + margin: auto; +} + +.banner { + margin: 1rem auto; + width: 100%; + border-radius: 5px; +} + +.banner > p { + text-align: center; +} + +.is-success { + background-color: #28cb28; + color: #efefef; +} + +.is-error { + background-color: #d42929; + color: #efefef; +} diff --git a/assets/img/logo.png b/assets/img/logo.png new file mode 100644 index 0000000..9278cb6 Binary files /dev/null and b/assets/img/logo.png differ diff --git a/assets/img/password.svg b/assets/img/password.svg new file mode 100644 index 0000000..db7eb11 --- /dev/null +++ b/assets/img/password.svg @@ -0,0 +1 @@ + diff --git a/assets/img/user.svg b/assets/img/user.svg new file mode 100644 index 0000000..74ce0d9 --- /dev/null +++ b/assets/img/user.svg @@ -0,0 +1 @@ + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..19fb9dc --- /dev/null +++ b/build.rs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(feature = "embed")] + minijinja_embed::embed_templates!("templates"); +} diff --git a/src/cli.rs b/src/cli.rs index f93abe3..d70e141 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,18 @@ use argh::FromArgs; +use camino::Utf8PathBuf; /// Run the llldap server #[derive(FromArgs)] pub struct CliArgs { - /// address or socket to listen on - #[argh(positional, default = "String::from(\"127.0.0.1:3389\")")] - pub listen: String, + /// path to the JSON file for the database + #[argh(option)] + pub db: Option, + + /// address or socket to listen on for LDAP connections + #[argh(option, default = "String::from(\"127.0.0.1:3389\")")] + pub listen_ldap: String, + + /// address or socket to listen on for HTTP connections + #[argh(option, default = "String::from(\"127.0.0.1:3390\")")] + pub listen_http: String, } diff --git a/src/db/common.rs b/src/db/common.rs index 4f278b8..3a5f655 100644 --- a/src/db/common.rs +++ b/src/db/common.rs @@ -3,22 +3,22 @@ use tokio::sync::RwLock; use std::sync::Arc; use crate::db::error::BoxedError; -use crate::db::{DatabaseInterface, UserRef}; +use crate::db::{DatabaseInterface, Domain, User, UserRef}; #[derive(Clone, Debug)] -pub struct Database { - pub inner: Arc>, +pub struct Database { + pub inner: Arc>>, } -impl Database { - pub fn new(db: D) -> Self { +impl Database { + pub fn new(db: impl DatabaseInterface) -> Self { Self { - inner: Arc::new(RwLock::new(db)), + inner: Arc::new(RwLock::new(Box::new(db))), } } } -impl Database { +impl Database { /// Return false if the user doesn't exist, or the password is wrong. /// /// TODO: should we return something else when the account doesn't exist? @@ -36,4 +36,13 @@ impl Database { tracing::debug!("Comparing {} and {}", user.password, password); Ok(user.password == password) } + + pub async fn domains_user_can_see(&self, user: &User) -> Result, BoxedError> { + Ok(self + .list_all_domains() + .await? + .into_iter() + .filter(|d| user.can_see_domain(&d.name)) + .collect()) + } } diff --git a/src/db/domain.rs b/src/db/domain.rs new file mode 100644 index 0000000..9c7d7e6 --- /dev/null +++ b/src/db/domain.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Domain { + pub name: String, +} diff --git a/src/db/error.rs b/src/db/error.rs index 573bbe1..9fdce05 100644 --- a/src/db/error.rs +++ b/src/db/error.rs @@ -5,12 +5,42 @@ use crate::db::UserRef; pub type BoxedError = Box; #[derive(Debug)] -pub struct UserAlreadyExists(pub UserRef); +pub enum UserCreationError { + DomainNotFound(String), + UserAlreadyExists(UserRef), + Permissions, +} -impl fmt::Display for UserAlreadyExists { +impl fmt::Display for UserCreationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "User already exists: {}", self.0) + match self { + Self::DomainNotFound(domain) => write!(f, "No domain {domain} to create user in"), + Self::UserAlreadyExists(user) => write!(f, "User already exists: {user}"), + Self::Permissions => write!(f, "You do not have permissions to create this user"), + } } } -impl std::error::Error for UserAlreadyExists {} +impl std::error::Error for UserCreationError {} + +#[derive(Debug)] +pub enum DomainCreationError { + DomainAlreadyExists(String), + InvalidDomain(String), +} + +impl fmt::Display for DomainCreationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DomainAlreadyExists(domain) => { + write!(f, "Cannot create domain {domain} because it already exists") + } + Self::InvalidDomain(domain) => write!( + f, + "Cannot create domain `{domain}` because it's not considered a valid domain" + ), + } + } +} + +impl std::error::Error for DomainCreationError {} diff --git a/src/db/filesystem.rs b/src/db/filesystem.rs new file mode 100644 index 0000000..4c805ec --- /dev/null +++ b/src/db/filesystem.rs @@ -0,0 +1,169 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use serde_json::Error as JsonError; + +use std::fmt; +use std::io::Error as IOError; + +use crate::db::error::{BoxedError, DomainCreationError, UserCreationError}; +use crate::db::{Database, DatabaseInterface, Domain, MemoryDatabase, User, UserRef}; + +#[derive(Debug)] +pub enum FilesystemDatabaseError { + ReadFileIO(Utf8PathBuf, IOError), + ReadFileJson(Utf8PathBuf, JsonError), + WriteFileIO(Utf8PathBuf, IOError), + WriteFileJson(Utf8PathBuf, JsonError), +} + +impl fmt::Display for FilesystemDatabaseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::ReadFileIO(path, e) => format!("Failed to read database file {path}: {e}"), + Self::ReadFileJson(path, e) => + format!("Failed to parse database JSON file {path}: {e}"), + Self::WriteFileIO(path, e) => format!("Failed to write database file {path}: {e}"), + Self::WriteFileJson(path, e) => + format!("Failed to convert database to JSON file {path}: {e}"), + } + ) + } +} + +impl std::error::Error for FilesystemDatabaseError {} + +#[derive(Clone, Debug, Default)] +pub struct FilesystemDatabase { + // TODO: Once we have common validation steps in place across DB backends, we can reduce cloning. + // For now, we clone the DB on every write operation, and update it when saving to disk + // succeeds. + pub inner: MemoryDatabase, + pub path: Utf8PathBuf, +} + +impl FilesystemDatabase { + pub async fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + + if !tokio::fs::try_exists(path) + .await + .map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))? + { + // The database doesn't exist yet, we create an empty one and try to write it + // to make sure the permissions are correct and the destination folder exists. + let mut db = Self { + inner: MemoryDatabase::default(), + path: path.to_path_buf(), + }; + + tracing::info!("Initializing empty database in file {path}. Checking permissions..."); + db.save_self().await?; + tracing::info!("Database successfully created"); + + return Ok(Database::new(db)); + } + + tracing::info!("Loading database from file {path}"); + let s = tokio::fs::read(path) + .await + .map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?; + let inner: MemoryDatabase = serde_json::from_slice(&s) + .map_err(|e| Box::new(FilesystemDatabaseError::ReadFileJson(path.to_path_buf(), e)))?; + Ok(Database::new(Self { + inner, + path: path.to_path_buf(), + })) + } + + /// Attempts to save a new state of the DB, effectively switching to the new state + /// only if the save is successful. + pub async fn save(&mut self, new_db: MemoryDatabase) -> Result<(), BoxedError> { + self.save_inner(&new_db).await?; + self.inner = new_db; + Ok(()) + } + + /// Saves an in-memory DB to disk. Cannot be used directly to observe exclusive access. + async fn save_inner(&self, inner: &MemoryDatabase) -> Result<(), BoxedError> { + let s = serde_json::to_string(inner) + .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?; + Ok(tokio::fs::write(&self.path, &s) + .await + .map_err(|e| Box::new(FilesystemDatabaseError::WriteFileIO(self.path.clone(), e)))?) + } + + /// Save the database without switching to a new state. + /// + /// Used when initializing the DB, to check for permissions. + pub async fn save_self(&mut self) -> Result<(), BoxedError> { + self.save_inner(&self.inner).await + } +} + +#[async_trait::async_trait] +impl DatabaseInterface for FilesystemDatabase { + async fn create_domain( + &mut self, + domain: &str, + ) -> Result, BoxedError> { + let mut new_db = self.inner.clone(); + + if let Err(e) = new_db.create_domain(domain).await? { + return Ok(Err(e)); + } + + self.save(new_db).await?; + Ok(Ok(())) + } + + async fn get_domain(&self, domain: &str) -> Result, BoxedError> { + self.inner.get_domain(domain).await + } + + async fn get_user(&self, req_user: &UserRef) -> Result, BoxedError> { + self.inner.get_user(req_user).await + } + + async fn create_user( + &mut self, + user: User, + ) -> Result, BoxedError> { + let mut new_db = self.inner.clone(); + + if let Err(e) = new_db.create_user(user).await? { + return Ok(Err(e)); + } + + self.save(new_db).await?; + Ok(Ok(())) + } + + async fn try_create_user( + &mut self, + new_user: User, + current_user: &User, + ) -> Result, BoxedError> { + let mut new_db = self.inner.clone(); + + if let Err(e) = new_db.try_create_user(new_user, current_user).await? { + return Ok(Err(e)); + } + + self.save(new_db).await?; + Ok(Ok(())) + } + + async fn list_all_domains(&self) -> Result, BoxedError> { + self.inner.list_all_domains().await + } + + async fn list_all_users(&self) -> Result, BoxedError> { + self.inner.list_all_users().await + } + + async fn list_domain_users(&self, domain: Option) -> Result, BoxedError> { + self.inner.list_domain_users(domain).await + } +} diff --git a/src/db/group.rs b/src/db/group.rs index 428d8f9..6a2793f 100644 --- a/src/db/group.rs +++ b/src/db/group.rs @@ -1,4 +1,6 @@ -#[derive(Clone, Debug)] +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Group { name: String, domain: String, diff --git a/src/db/interface.rs b/src/db/interface.rs index 74ce9ee..36d6266 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -1,7 +1,19 @@ -use crate::db::error::{BoxedError, UserAlreadyExists}; -use crate::db::{Database, User, UserRef}; +use crate::db::error::{BoxedError, DomainCreationError, UserCreationError}; +use crate::db::{Database, Domain, User, UserRef}; + +#[async_trait::async_trait] +impl DatabaseInterface for Database { + async fn create_domain( + &mut self, + domain: &str, + ) -> Result, BoxedError> { + self.inner.write().await.create_domain(domain).await + } + + async fn get_domain(&self, domain: &str) -> Result, BoxedError> { + self.inner.read().await.get_domain(domain).await + } -impl DatabaseInterface for Database { async fn get_user(&self, user: &UserRef) -> Result, BoxedError> { self.inner.read().await.get_user(user).await } @@ -9,15 +21,59 @@ impl DatabaseInterface for Database { async fn create_user( &mut self, user: User, - ) -> Result, BoxedError> { + ) -> Result, BoxedError> { self.inner.write().await.create_user(user).await } + + async fn try_create_user( + &mut self, + new_user: User, + current_user: &User, + ) -> Result, BoxedError> { + self.inner + .write() + .await + .try_create_user(new_user, current_user) + .await + } + + async fn list_all_domains(&self) -> Result, BoxedError> { + self.inner.read().await.list_all_domains().await + } + + async fn list_all_users(&self) -> Result, BoxedError> { + self.inner.read().await.list_all_users().await + } + + async fn list_domain_users(&self, domain: Option) -> Result, BoxedError> { + self.inner.read().await.list_domain_users(domain).await + } } -pub trait DatabaseInterface { +#[async_trait::async_trait] +pub trait DatabaseInterface: std::fmt::Debug + Send + Sync + 'static { + async fn create_domain( + &mut self, + domain: &str, + ) -> Result, BoxedError>; + async fn get_domain(&self, domain: &str) -> Result, BoxedError>; + async fn get_user(&self, user: &UserRef) -> Result, BoxedError>; async fn create_user( &mut self, user: User, - ) -> Result, BoxedError>; + ) -> Result, BoxedError>; + async fn try_create_user( + &mut self, + new_user: User, + current_user: &User, + ) -> Result, BoxedError>; + + async fn list_all_domains(&self) -> Result, BoxedError>; + async fn list_all_users(&self) -> Result, BoxedError>; + + /// List users on a specific domain. + /// + /// A `None` domain requested lists global service users. + async fn list_domain_users(&self, domain: Option) -> Result, BoxedError>; } diff --git a/src/db/memory.rs b/src/db/memory.rs index 186486c..76a53be 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -1,44 +1,119 @@ -use std::future::{Future, ready}; +use serde::{Deserialize, Serialize}; -use crate::db::error::{BoxedError, UserAlreadyExists}; -use crate::db::{Database, DatabaseInterface, Group, User, UserRef}; +use crate::db::error::{BoxedError, DomainCreationError, UserCreationError}; +use crate::db::{Database, DatabaseInterface, Domain, Group, Operation, User, UserRef}; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct MemoryDatabase { - pub users: Vec, + // We store data in tables like in SQL + pub domains: Vec, pub groups: Vec, + pub users: Vec, } impl MemoryDatabase { - pub fn new() -> Database { + #[allow(clippy::new_ret_no_self)] + pub fn new() -> Database { + tracing::warn!("Using in-memory database. Data will not be saved across restarts!"); Database::new(Self::default()) } } +#[async_trait::async_trait] impl DatabaseInterface for MemoryDatabase { - fn get_user( - &self, - req_user: &UserRef, - ) -> impl Future, BoxedError>> { - for user in &self.users { - if user.username == req_user.username && user.domain == req_user.domain { - return ready(Ok(Some(user.clone()))); - } + async fn create_domain( + &mut self, + domain: &str, + ) -> Result, BoxedError> { + if domain.is_empty() { + return Ok(Err(DomainCreationError::InvalidDomain(domain.to_string()))); } - ready(Ok(None)) + if self.domains.iter().find(|d| d.name == domain).is_some() { + return Ok(Err(DomainCreationError::DomainAlreadyExists( + domain.to_string(), + ))); + } + + self.domains.push(Domain { + name: domain.to_string(), + }); + + Ok(Ok(())) + } + + async fn get_domain(&self, domain: &str) -> Result, BoxedError> { + let Some(domain) = self.domains.iter().find(|d| d.name == domain) else { + return Ok(None); + }; + + Ok(Some(domain.clone())) + } + + async fn get_user(&self, req_user: &UserRef) -> Result, BoxedError> { + Ok(self + .users + .iter() + .find(|u| u.username == req_user.username && u.domain == req_user.domain) + .cloned()) } async fn create_user( &mut self, user: User, - ) -> Result, BoxedError> { + ) -> Result, BoxedError> { let user_ref = user.user_ref(); + if self.get_user(&user_ref).await?.is_some() { - return Ok(Err(UserAlreadyExists(user_ref))); + return Ok(Err(UserCreationError::UserAlreadyExists(user_ref))); + } + + // If a domain is requested (i.e. not a global user), make sure the domain exists + if let Some(req_domain) = &user.domain + && self.get_domain(req_domain).await?.is_none() + { + return Ok(Err(UserCreationError::DomainNotFound(req_domain.clone()))); } self.users.push(user); Ok(Ok(())) } + + async fn try_create_user( + &mut self, + new_user: User, + current_user: &User, + ) -> Result, BoxedError> { + // First check permissions, then apply the operation + // + // TODO: for now we don't allow creating service users manually + // so we assume there's a domain provided + let Some(new_user_domain) = &new_user.domain else { + return Ok(Err(UserCreationError::Permissions)); + }; + + let op = Operation::CreateUser(new_user_domain.clone()); + if !current_user.can_perform(&op) { + return Ok(Err(UserCreationError::Permissions)); + } + + self.create_user(new_user).await + } + + async fn list_all_domains(&self) -> Result, BoxedError> { + Ok(self.domains.clone()) + } + + async fn list_all_users(&self) -> Result, BoxedError> { + Ok(self.users.clone()) + } + + async fn list_domain_users(&self, domain: Option) -> Result, BoxedError> { + Ok(self + .users + .iter() + .filter(|u| u.domain == domain) + .cloned() + .collect()) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index b54747a..902e89c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,11 +1,17 @@ mod common; pub use common::Database; +mod domain; +pub use domain::Domain; pub mod error; +mod filesystem; +pub use filesystem::FilesystemDatabase; mod group; pub use group::Group; mod interface; pub use interface::DatabaseInterface; mod memory; pub use memory::MemoryDatabase; +mod role; +pub use role::{Operation, Role}; mod user; pub use user::{User, UserRef}; diff --git a/src/db/role.rs b/src/db/role.rs new file mode 100644 index 0000000..37f6b51 --- /dev/null +++ b/src/db/role.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug)] +pub enum Operation { + CreateDomain, + CreateUser(String), + ListUsers(Option), +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub enum Role { + /// Can do anything + Admin, + /// Can only read data across all vhosts + ReadonlyAdmin, + /// Can do anything on a domain, except removing + /// oneself as a domain admin. + /// + /// Can not give away roles other than DomainModerator/User + DomainAdmin(String), + /// Can create users and reset passwords on a domain + DomainModerator(String), + /// Can only edit own profile + User, +} + +impl Role { + pub fn can_perform(&self, operation: &Operation) -> bool { + match operation { + Operation::CreateDomain => self == &Self::Admin, + Operation::CreateUser(op_domain) => match self { + Self::Admin => true, + Self::DomainAdmin(usr_domain) | Self::DomainModerator(usr_domain) => { + op_domain == usr_domain + } + _ => false, + }, + Operation::ListUsers(op_domain) => match self { + Self::Admin => true, + Self::DomainAdmin(usr_domain) | Self::DomainModerator(usr_domain) => { + op_domain.as_ref() == Some(usr_domain) + } + _ => false, + }, + } + } + + pub fn can_see_domain(&self, domain: &str) -> bool { + match self { + Self::Admin | Self::ReadonlyAdmin => true, + Self::DomainAdmin(d) | Self::DomainModerator(d) => domain == d, + Self::User => false, + } + } +} diff --git a/src/db/user.rs b/src/db/user.rs index 4301d73..ba9e986 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -1,23 +1,71 @@ +use serde::{Deserialize, Serialize}; + use std::fmt; +use crate::db::{Operation, Role}; + +/// A requested user/domain combo for login, lowercased. +/// +/// Domain may be empty, but a value with more than one +/// `@` is considered invalid. #[derive(Clone, Debug)] pub struct UserRef { pub username: String, - pub domain: String, + pub domain: Option, +} + +#[derive(Clone, Debug)] +pub struct InvalidUserRef(pub String); + +impl std::error::Error for InvalidUserRef {} + +impl fmt::Display for InvalidUserRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid username: {}", self.0) + } +} + +impl UserRef { + pub fn from_user_maybe_domain(value: &str) -> Result { + let value = value.to_lowercase(); + + let mut parts = value.split('@'); + let username = parts.next().unwrap(); + let domain = parts.next(); + + if parts.next().is_some() { + return Err(InvalidUserRef(value.clone())); + } + + Ok(Self { + username: username.to_string(), + domain: domain.map(Into::into), + }) + } } impl fmt::Display for UserRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}@{}", self.username, self.domain) + if let Some(domain) = &self.domain { + write!(f, "{}@{}", self.username, domain) + } else { + write!(f, "{}", self.username) + } } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct User { + /// Username, without the domain part. Once set, cannot be edited. pub username: String, - pub domain: String, + /// Domain of the user. Once set, cannot be edited. + /// + /// Service accounts may exist with an empty domain. + pub domain: Option, pub password: String, + /// Mail for the user. Computed from username/domain, cannot be edited. pub mail: String, + pub role: Role, // recovery_mail: String, } @@ -28,10 +76,28 @@ impl User { domain: self.domain.clone(), } } + + pub fn can_perform(&self, operation: &Operation) -> bool { + self.role.can_perform(operation) + } + + pub fn can_create_domain(&self) -> bool { + self.role.can_perform(&Operation::CreateDomain) + } + + pub fn can_see_domain(&self, domain: &str) -> bool { + let allowed = self.role.can_see_domain(domain); + tracing::debug!("{} can see domain {}: {}", self.mail, domain, allowed); + allowed + } } impl fmt::Display for User { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}@{}", self.username, self.domain) + if let Some(domain) = &self.domain { + write!(f, "{}@{}", self.username, domain) + } else { + write!(f, "{}", self.username) + } } } diff --git a/src/http/domain.rs b/src/http/domain.rs new file mode 100644 index 0000000..4c05af0 --- /dev/null +++ b/src/http/domain.rs @@ -0,0 +1,71 @@ +use axum::extract::{Form, Path, State}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use http::StatusCode; +use minijinja::context; +use serde::Deserialize; + +use crate::db::{DatabaseInterface, Operation}; +use crate::http::{HttpSession, HttpState}; + +pub async fn get_domain( + State(state): State, + session: HttpSession, + Path(domain): Path, +) -> Response { + let domain = match state.db.get_domain(&domain).await { + Ok(Some(domain)) => domain, + Ok(None) => return format!("Domain not found: {domain}").into_response(), + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + let domain_users = match state.db.list_domain_users(Some(domain.name.clone())).await { + Ok(users) => users, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + // Redundant because someone who can see the domain admin page for the moment + // always can create accounts. + let op = Operation::CreateUser(domain.name.clone()); + let can_create_user = session.user.can_perform(&op); + + let ctx = context! { + can_create_user, + domain, + user => session.user, + users => domain_users, + }; + + let page = state + .templates + .get_template("domain.html") + .unwrap() + .render(ctx) + .unwrap(); + (StatusCode::OK, Html(page)).into_response() +} + +#[derive(Clone, Debug, Deserialize)] +pub struct DomainCreationForm { + domainname: String, +} + +pub async fn create_domain( + State(mut state): State, + session: HttpSession, + Form(form): Form, +) -> Response { + let op = Operation::CreateDomain; + if !session.user.can_perform(&op) { + return "Not authorized to create a new domain".into_response(); + } + + match state.db.create_domain(&form.domainname).await { + Ok(Ok(())) => Redirect::to(&format!("/domain/{}", form.domainname)).into_response(), + Ok(Err(e)) => format!("Failed to create domain {}: {}", form.domainname, e).into_response(), + Err(e) => format!("Database error: {e}").into_response(), + } +} diff --git a/src/http/home.rs b/src/http/home.rs new file mode 100644 index 0000000..32802b0 --- /dev/null +++ b/src/http/home.rs @@ -0,0 +1,58 @@ +use axum::extract::State; +use axum::response::{Html, IntoResponse, Response}; +use http::StatusCode; +use minijinja::context; + +use crate::db::{DatabaseInterface, Operation}; +use crate::http::{HttpSession, HttpState}; + +pub async fn home( + State(state): State, + // Only logged in users are allowed here + session: HttpSession, +) -> Response { + // When the user has no domain (service admin) list all domains + let op = Operation::ListUsers(session.user.domain.clone()); + let other_users = if session.user.can_perform(&op) { + match state + .db + .list_domain_users(session.user.domain.clone()) + .await + { + Ok(other_users) => other_users, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + } + } else { + vec![] + }; + + tracing::info!( + "Found {} users on domain {:?}", + other_users.len(), + session.user.domain + ); + + let domains = match state.db.domains_user_can_see(&session.user).await { + Ok(domains) => domains, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + let ctx = context! { + domains, + user => session.user, + can_create_domain => session.user.can_create_domain(), + other_users, + }; + + let page = state + .templates + .get_template("home.html") + .unwrap() + .render(ctx) + .unwrap(); + (StatusCode::OK, Html(page)).into_response() +} diff --git a/src/http/login.rs b/src/http/login.rs new file mode 100644 index 0000000..7a65998 --- /dev/null +++ b/src/http/login.rs @@ -0,0 +1,95 @@ +use axum::extract::{Form, State}; +use axum::response::{Html, IntoResponse, Response}; +use axum_extra::extract::cookie::CookieJar; +use http::StatusCode; +use minijinja::context; +use serde::{Deserialize, Serialize}; + +use crate::db::{DatabaseInterface, UserRef}; +use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; + +#[derive(Debug, Deserialize)] +pub struct LoginForm { + username: String, + password: String, +} + +#[derive(Debug, Serialize)] +pub enum LoginError { + InvalidCredentials, + SessionInvalidated, +} + +pub async fn login_page( + State(state): State, + login_error: Option, + redirect: InternalRedirect, +) -> Response { + let page = state + .templates + .get_template("login.html") + .unwrap() + .render(context! {login_error => login_error, redirect => format!("/login/?redirect={}", redirect.to_base64url())}) + .unwrap(); + (StatusCode::OK, Html(page)).into_response() +} + +pub async fn get_login( + State(state): State, + maybe_session: Option, + redirect: InternalRedirect, +) -> Response { + if maybe_session.is_some() { + return redirect.to_redirect().into_response(); + } + + login_page(State(state), None, redirect).await +} + +pub async fn post_login( + State(state): State, + session: Option, + cookies: CookieJar, + redirect: InternalRedirect, + Form(form): Form, +) -> Response { + if session.is_some() { + // Already logged in + return (cookies, redirect.to_redirect()).into_response(); + } + + let req_user = match UserRef::from_user_maybe_domain(&form.username) { + Ok(req_user) => req_user, + Err(e) => { + return e.to_string().into_response(); + } + }; + + let success = match state.db.check_password(&req_user, &form.password).await { + Ok(success) => success, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + if success { + let maybe_user = match state.db.get_user(&req_user).await { + Ok(user) => user, + Err(e) => { + return format!("Database error: {e}").into_response(); + } + }; + + let Some(user) = maybe_user else { + return "Woops, user has been deleted while you were logging in. What are the chances?!".to_string() + .into_response(); + }; + + let cookies = state.sessions.add_session(user, cookies); + (cookies, redirect.to_redirect()).into_response() + } else { + login_page(State(state), Some(LoginError::InvalidCredentials), redirect) + .await + .into_response() + } +} diff --git a/src/http/logout.rs b/src/http/logout.rs new file mode 100644 index 0000000..844633d --- /dev/null +++ b/src/http/logout.rs @@ -0,0 +1,28 @@ +use axum::extract::State; +use axum::response::{IntoResponse, Redirect, Response}; +use axum_extra::extract::cookie::CookieJar; + +use crate::http::login::{LoginError, login_page}; +use crate::http::{HttpState, InternalRedirect, OptionalHttpSession}; + +pub async fn logout( + State(state): State, + session: Option, + cookies: CookieJar, +) -> Response { + let Some(session) = session else { + return (cookies, Redirect::to("/")).into_response(); + }; + + let cookies = state.sessions.remove_session(&session, cookies); + ( + cookies, + login_page( + State(state), + Some(LoginError::SessionInvalidated), + InternalRedirect::new(), + ) + .await, + ) + .into_response() +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..e5b8ae1 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,112 @@ +use axum::Router; +use axum::routing::{get, post}; +use axum::serve::Listener as AxumListener; +use minijinja::Environment; +#[cfg(not(feature = "embed"))] +use minijinja::path_loader; +#[cfg(feature = "embed")] +use static_serve::embed_assets; +#[cfg(not(feature = "embed"))] +use tower_http::services::ServeDir; + +use crate::db::Database; +use crate::listener::{Listener, ListenerKind}; +use crate::stream::{AbstractSocketAddr, AbstractStreamKind}; + +mod domain; +mod home; +mod login; +mod logout; +mod redirect; +use redirect::InternalRedirect; +mod session; +use session::{HttpSession, HttpSessionManager, OptionalHttpSession}; +mod user; + +impl AxumListener for Listener { + type Io = AbstractStreamKind; + type Addr = AbstractSocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let res = match &self.kind { + ListenerKind::Tcp(l) => l + .accept() + .await + .map(|(stream, remote_addr)| (stream.into(), remote_addr.into())), + ListenerKind::Uds(l) => l + .accept() + .await + .map(|(stream, remote_addr)| (stream.into(), remote_addr.into())), + }; + + match res { + Ok((stream, remote_addr)) => return (stream, remote_addr), + Err(e) => { + // Here the error could be fatal, or could simply be that a client aborted the connected, + // in which case we don't want to crash the server, simply skip this client connection. + // https://doc.rust-lang.org/stable/std/net/struct.TcpListener.html#errors + match e.kind() { + std::io::ErrorKind::ConnectionAborted => {} + _ => panic!("Unrecoverable HTTP client connection error: {e}"), + } + } + } + } + } + + fn local_addr(&self) -> std::io::Result { + match &self.kind { + ListenerKind::Tcp(l) => l.local_addr().map(Into::into), + ListenerKind::Uds(l) => l.local_addr().map(Into::into), + } + } +} + +#[derive(Clone)] +pub struct HttpState { + pub db: Database, + pub sessions: HttpSessionManager, + pub templates: Environment<'static>, +} + +impl HttpState { + pub fn new(db: Database) -> Self { + let mut templates = Environment::new(); + #[cfg(feature = "embed")] + minijinja_embed::load_templates!(&mut templates); + #[cfg(not(feature = "embed"))] + templates.set_loader(path_loader("templates")); + Self { + db, + sessions: HttpSessionManager::new(), + templates, + } + } +} + +pub async fn http_listen(listener: Listener, db: Database) { + #[cfg(all(feature = "embed", feature = "noembed"))] + compile_error!("You cannot have `embed` and `noembed` features enabled at the same time."); + #[cfg(not(any(feature = "embed", feature = "noembed")))] + compile_error!("You must have `embed` or `noembed` feature enabled."); + + #[cfg(feature = "embed")] + let app = { + embed_assets!("assets"); + Router::new().nest("/assets", static_router()) + }; + #[cfg(not(feature = "embed"))] + let app = { Router::new().nest_service("/assets", ServeDir::new("assets")) }; + let app = app + .route("/", get(home::home)) + .route("/login", get(login::get_login)) + .route("/login", post(login::post_login)) + .route("/logout", get(logout::logout)) + .route("/domain/{domain}", get(domain::get_domain)) + .route("/domain", post(domain::create_domain)) + .route("/user", post(user::create_user)) + .with_state(HttpState::new(db)); + + axum::serve(listener, app).await.unwrap(); +} diff --git a/src/http/redirect.rs b/src/http/redirect.rs new file mode 100644 index 0000000..120f9c8 --- /dev/null +++ b/src/http/redirect.rs @@ -0,0 +1,96 @@ +use axum::extract::{FromRequestParts, Query}; +use axum::response::Redirect; +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use http::request::Parts; +use serde::Deserialize; + +use crate::http::HttpState; + +/// Internal redirect to a different page. +/// +/// Is usually constructed from a base64url-encoded `redirect` +/// query string, for example in the login page. +pub struct InternalRedirect(Option); + +impl InternalRedirect { + /// Creates a new redirection from a base64url-encoded string. + /// + /// An invalid redirect is silently discarded and treated as no redirect. + pub fn from_base64url(s: &str) -> Self { + let s = match URL_SAFE.decode(s) { + Ok(s) => s, + Err(e) => { + tracing::debug!("Malformed login redirect URL, treating as empty: {e}"); + return Self::new(); + } + }; + + let s = match String::from_utf8(s) { + Ok(s) => s, + Err(e) => { + tracing::debug!("Malformed login redirect URL bytes, treating as empty: {e}"); + return Self::new(); + } + }; + + Self::from_string(s) + } + + /// Turns into a base64url string that can be added to a URL. + pub fn to_base64url(&self) -> String { + let s = self + .0 + .as_ref() + .expect("Cannot call InternalRedirect::to_base64url on an empty redirect"); + URL_SAFE.encode(s) + } + + /// Creates a new redirection from a raw string. + /// + /// If the string is empty or seems to redirect outside of our website (absolute URL), + /// the redirect is silently discarded and treated as no redirect. + pub fn from_string(s: String) -> Self { + if s.trim().is_empty() { + return Self(None); + } + + if s.starts_with("http://") || s.starts_with("https://") { + tracing::debug!("Invalid login redirect URL, treating as empty: {s}"); + return Self(None); + } + + Self(Some(s)) + } + + pub fn new() -> Self { + Self(None) + } + + pub fn to_redirect(&self) -> Redirect { + if let Some(url) = &self.0 { + Redirect::to(url) + } else { + Redirect::to("/") + } + } +} + +impl FromRequestParts for InternalRedirect { + type Rejection = !; + + async fn from_request_parts( + parts: &mut Parts, + state: &HttpState, + ) -> Result { + let Ok(f) = Query::::from_request_parts(parts, state).await else { + return Ok(Self::new()); + }; + + Ok(Self::from_base64url(&f.redirect)) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RedirectForm { + redirect: String, +} diff --git a/src/http/session.rs b/src/http/session.rs new file mode 100644 index 0000000..fd73975 --- /dev/null +++ b/src/http/session.rs @@ -0,0 +1,137 @@ +use axum::extract::{FromRequestParts, OptionalFromRequestParts}; +use axum::response::Redirect; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use http::request::Parts; +use http::uri::Uri; +use uuid::Uuid; + +use std::sync::{Arc, RwLock}; + +use crate::db::User; +use crate::http::{HttpState, InternalRedirect}; + +pub const COOKIE_NAME: &str = "lldap_session"; + +#[derive(Clone, Debug)] +pub struct HttpSessionManager { + inner: Arc>>, +} + +impl HttpSessionManager { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(vec![])), + } + } + + pub fn add_session(&self, user: User, cookies: CookieJar) -> CookieJar { + let uuid = Uuid::new_v4(); + + let mut cookie = Cookie::new(COOKIE_NAME, uuid.to_string()); + cookie.set_path("/"); + + let session = HttpSession { user, uuid }; + + { + self.inner.write().unwrap().push(session); + } + + cookies.add(cookie) + } + + pub fn get_session(&self, cookies: &CookieJar) -> Option { + let cookie = cookies.get(COOKIE_NAME)?; + let previous_uuid = Uuid::parse_str(cookie.value()).ok()?; + + self.inner + .read() + .unwrap() + .iter() + .find(|session| session.uuid == previous_uuid) + .cloned() + } + + pub fn remove_session(&self, session: &HttpSession, cookies: CookieJar) -> CookieJar { + let mut cookie = Cookie::new(COOKIE_NAME, String::new()); + cookie.set_path("/"); + + let idx = { + let Some(idx) = self.inner.read().unwrap().iter().position(|s| s == session) else { + return cookies; + }; + idx + }; + + { + self.inner.write().unwrap().remove(idx); + } + + cookies.remove(cookie) + } +} + +#[derive(Clone, Debug)] +pub struct HttpSession { + // TODO: by storing the session here, it means + // it needs to be updated every time we change the user. + pub user: User, + pub uuid: Uuid, +} + +impl PartialEq for HttpSession { + fn eq(&self, other: &Self) -> bool { + self.uuid == other.uuid + } +} + +impl FromRequestParts for HttpSession { + type Rejection = Redirect; + + async fn from_request_parts( + parts: &mut Parts, + state: &HttpState, + ) -> Result { + let cookies = CookieJar::from_request_parts(parts, state).await.unwrap(); + if let Some(session) = state.sessions.get_session(&cookies) { + return Ok(session); + } + + // Extract the requested URL, turn it into base64, to let the login page + // know where to redirect us. + // But first, remove the scheme/host/port from URL. + let s = Uri::builder() + .path_and_query(parts.uri.path_and_query().unwrap().clone()) + .build() + .unwrap(); + let r = InternalRedirect::from_string(s.to_string()); + Err(Redirect::to(&format!( + "/login?redirect={}", + r.to_base64url() + ))) + } +} + +pub struct OptionalHttpSession(pub HttpSession); + +impl std::ops::Deref for OptionalHttpSession { + type Target = HttpSession; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl OptionalFromRequestParts for OptionalHttpSession { + type Rejection = Redirect; + + async fn from_request_parts( + parts: &mut Parts, + state: &HttpState, + ) -> Result, Self::Rejection> { + let maybe_session = HttpSession::from_request_parts(parts, state) + .await + .ok() + .map(Self); + Ok(maybe_session) + } +} diff --git a/src/http/user.rs b/src/http/user.rs new file mode 100644 index 0000000..00fd976 --- /dev/null +++ b/src/http/user.rs @@ -0,0 +1,37 @@ +use axum::extract::{Form, State}; +use axum::response::{IntoResponse, Redirect, Response}; +use serde::Deserialize; + +use crate::db::{DatabaseInterface, Role, User}; +use crate::http::{HttpSession, HttpState}; + +#[derive(Clone, Debug, Deserialize)] +pub struct UserCreationForm { + pub username: String, + pub domain: String, + pub password: String, +} + +pub async fn create_user( + State(mut state): State, + session: HttpSession, + Form(form): Form, +) -> Response { + let new_user = User { + mail: format!("{}@{}", form.username, form.domain), + username: form.username.clone(), + domain: Some(form.domain.clone()), + password: form.password, + role: Role::User, + }; + + match state.db.try_create_user(new_user, &session.user).await { + Ok(Ok(())) => Redirect::to(&format!("/domain/{}", form.domain)).into_response(), + Ok(Err(e)) => format!( + "Failed to create user {} on domain {}: {}", + form.username, form.domain, e + ) + .into_response(), + Err(e) => format!("Database error: {e}").into_response(), + } +} diff --git a/src/ldap/attr.rs b/src/ldap/attr.rs new file mode 100644 index 0000000..14bd72a --- /dev/null +++ b/src/ldap/attr.rs @@ -0,0 +1,66 @@ +use std::fmt; +use std::str::FromStr; + +#[derive(Clone, Debug, PartialEq)] +pub struct UnknownLdapAttribute(String); + +impl fmt::Display for UnknownLdapAttribute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Unknown LDAP attribute: {}", self.0) + } +} + +impl std::error::Error for UnknownLdapAttribute {} + +/// An LDAP attribute that is requested, or requested to be matched against an entry. +/// +/// Attributes are case-insensitive when parsing from a string. +/// +/// In the future, we may want to support custom attributes, but that is not +/// implemented for now. +#[derive(Clone, Debug, PartialEq)] +pub enum LdapAttribute { + Uid, + CommonName, + MemberOf, + ObjectClass, + Mail, + MailAlias, +} + +impl FromStr for LdapAttribute { + type Err = UnknownLdapAttribute; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "uid" => Ok(Self::Uid), + "cn" => Ok(Self::CommonName), + "memberof" => Ok(Self::MemberOf), + "objectclass" => Ok(Self::ObjectClass), + "mail" => Ok(Self::Mail), + "mailalias" => Ok(Self::MailAlias), + _ => Err(UnknownLdapAttribute(s.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uppercased_attribute() { + let s = "MemberOF"; + let attr = LdapAttribute::from_str(s); + println!("{attr:?}"); + assert_eq!(attr.unwrap(), LdapAttribute::MemberOf); + } + + #[test] + fn unknown_attribute() { + let s = "foobar"; + let attr = LdapAttribute::from_str(s); + println!("{attr:?}"); + assert_eq!(attr.unwrap_err(), UnknownLdapAttribute(s.to_string())); + } +} diff --git a/src/ldap/dn.rs b/src/ldap/dn.rs index 87922aa..33371fe 100644 --- a/src/ldap/dn.rs +++ b/src/ldap/dn.rs @@ -137,6 +137,7 @@ impl Dn { /// Overrides the DN hostname (`dc` fields) with the provided host. /// /// When no `dc` fields are present, they are only added when `force` is true. + #[expect(unused)] pub fn set_hostname(&mut self, host: &str, force: bool) { let domain_components: Vec = host.split('.').map(|x| dn_escape(x).to_string()).collect(); @@ -148,8 +149,10 @@ impl Dn { let mut keys = VecMap::new(); keys.insert_or_append("uid", &user.username); keys.insert_or_append("ou", "people"); - for domain_component in user.domain.split('.') { - keys.insert_or_append("dc", domain_component); + if let Some(domain_components) = &user.domain { + for domain_component in domain_components.split('.') { + keys.insert_or_append("dc", domain_component); + } } Self { keys } diff --git a/src/ldap/filter/mail.rs b/src/ldap/filter/mail.rs deleted file mode 100644 index 0f17ac0..0000000 --- a/src/ldap/filter/mail.rs +++ /dev/null @@ -1,248 +0,0 @@ -use ldap3_proto::{LdapFilter, LdapResultCode}; - -use std::fmt; - -use crate::db::UserRef; -use crate::ldap::LdapReturnError; - -#[derive(Clone, Debug)] -pub struct MailFilter { - pub complete: String, - pub username: String, - pub domain: String, -} - -impl fmt::Display for MailFilter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.complete) - } -} - -impl MailFilter { - /// Tries to parse a string into an email address. - /// - /// This method is not really RFC-compliant because it merely splits by `@`, - /// but is good enough for what we do. - pub fn new(value: &str) -> Result { - let original = value; - // Here we normalize the mail attribute to lowercase to prevent - // useless mismatches. - // TODO: investigate if that's supposed to be a problem for anyone? - let value = value.to_lowercase(); - if value != original { - tracing::debug!("Normalized search email from {original} to {value}"); - } - - let mut parts = value.split('@'); - let username = parts.next().unwrap(); - let Some(domain) = parts.next() else { - tracing::debug!("Not a valid username@domain email: {value}"); - return Err(MailDomainError::InvalidMail); - }; - - if parts.next().is_some() { - tracing::debug!("Too many parts in mail address"); - return Err(MailDomainError::InvalidMail); - } - - Ok(Self { - complete: value.clone(), - username: username.to_string(), - domain: domain.to_string(), - }) - } - - /// Extract any mail filter from an LDAP search filter - /// which may contain other criteria, which we overall don't care about - /// at the moment. - pub fn from_search_filter(filter: &LdapFilter) -> Result { - let res = match filter { - LdapFilter::And(filters) | LdapFilter::Or(filters) => { - Self::from_multiple_filters(filters) - } - LdapFilter::Equality(attr, value) => { - if let Some(domain) = Self::from_equality_filter(attr, value)? { - Ok(domain) - } else { - Err(MailDomainError::NoMailFilter) - } - } - _ => Err(MailDomainError::InvalidFilter), - }; - - match &res { - Ok(mail) => tracing::debug!("Found email in search filter: {mail}"), - Err(e) => tracing::debug!("Not found email in search filter: {}", e.message()), - } - - res - } - - /// Extract any mail filter from a bunch of LDAP filters. - /// - /// Any filter that is not an equality check is discarded. - fn from_multiple_filters(filters: &[LdapFilter]) -> Result { - for filter in filters { - if let LdapFilter::Equality(attr, value) = filter { - // Here if we receive None, it means the filter was not checking for the - // `mail` attr so we continue iterating. - if let Some(found) = Self::from_equality_filter(attr, value)? { - return Ok(found); - } - } - } - - Err(MailDomainError::NoMailFilter) - } - - /// Extract any mail filter from an LDAP search equality filter extracted - /// from a global search filter. - fn from_equality_filter(attr: &str, value: &str) -> Result, MailDomainError> { - if attr != "mail" { - return Ok(None); - } - - let mail = Self::new(value)?; - Ok(Some(mail)) - } - - pub fn to_user_ref(&self) -> UserRef { - UserRef { - username: self.username.clone(), - domain: self.domain.clone(), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum MailDomainError { - /// mail value not user@domain format - InvalidMail, - /// No mail filter found in the search query - NoMailFilter, - /// Filter is not And/Or/Equality for which we can find a mail filter - InvalidFilter, -} - -impl fmt::Display for MailDomainError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let msg = match self { - Self::InvalidMail => { - "No valid email address requested in mail filter in search request" - } - Self::NoMailFilter => "No mail filter found in search request", - Self::InvalidFilter => "No AND/OR/EQUALITY filter found in search request", - }; - write!(f, "{msg}") - } -} - -impl LdapReturnError for MailDomainError { - fn code(&self) -> LdapResultCode { - match self { - Self::InvalidMail => LdapResultCode::InvalidAttributeSyntax, - Self::NoMailFilter => LdapResultCode::InappropriateMatching, - Self::InvalidFilter => LdapResultCode::UnwillingToPerform, - } - } - - fn message(&self) -> String { - self.to_string() - } -} - -#[cfg(test)] -mod tests { - use crate::filter::{search_filter_and, search_filter_eq}; - - use super::*; - - #[test] - fn no_mail_filter() { - let filter = search_filter_and(&[ - search_filter_eq("uid", "a"), - search_filter_eq("objectClass", "inetOrgPerson"), - ]); - - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::NoMailFilter); - } - - #[test] - fn invalid_filter() { - let filter = LdapFilter::Approx("mail".to_string(), "a@a.localhost".to_string()); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::InvalidFilter); - } - - #[test] - fn invalid_mail_no_domain() { - let filter = search_filter_eq("mail", "a"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::InvalidMail); - } - - #[test] - fn invalid_mail_too_many_parts() { - let filter = search_filter_eq("mail", "a@a.localhost@a.localhost"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let e = mail_filter.unwrap_err(); - assert_eq!(e, MailDomainError::InvalidMail); - } - - #[test] - fn valid_basic() { - let filter = search_filter_eq("mail", "a@a.localhost"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let mail = mail_filter.unwrap(); - assert_eq!(mail.complete, "a@a.localhost"); - assert_eq!(mail.username, "a"); - assert_eq!(mail.domain, "a.localhost"); - } - - #[test] - fn valid_basic_normalization() { - let filter = search_filter_eq("mail", "A@A.localhost"); - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let mail = mail_filter.unwrap(); - assert_eq!(mail.complete, "a@a.localhost"); - assert_eq!(mail.username, "a"); - assert_eq!(mail.domain, "a.localhost"); - } - - #[test] - fn stalwart_default() { - // (&(objectClass=inetOrgPerson)(mail=?)) - let filter = search_filter_and(&[ - search_filter_eq("mail", "a@a.localhost"), - search_filter_eq("objectClass", "inetOrgPerson"), - ]); - - let mail_filter = MailFilter::from_search_filter(&filter); - println!("{:?}", mail_filter); - let mail = mail_filter.unwrap(); - assert_eq!(mail.complete, "a@a.localhost"); - assert_eq!(mail.username, "a"); - assert_eq!(mail.domain, "a.localhost"); - } - - // fn stalwart_lldap_example() { - // // &(|(objectClass=person)(member=cn=mail,ou=groups,dc=example,dc=org))(uid=?)) - // let filter = search_filter_and(&[ - // search_filter_eq("uid", "a"), - // search_filter_or(&[ - // search_filter_eq("objectClass", "person"), - // search_filter_eq("member", "cn=mail,ou=groups,dc=a,dc=localhost"), - // ]), - // ]); - // } -} diff --git a/src/ldap/filter/mod.rs b/src/ldap/filter/mod.rs deleted file mode 100644 index f764e1c..0000000 --- a/src/ldap/filter/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod mail; diff --git a/src/ldap/handler.rs b/src/ldap/handler.rs index 32d2ac0..67ed863 100644 --- a/src/ldap/handler.rs +++ b/src/ldap/handler.rs @@ -1,13 +1,13 @@ use ldap3_proto::LdapMsg; use ldap3_proto::proto::LdapOp; -use crate::db::{Database, DatabaseInterface}; +use crate::db::Database; use crate::ldap::{ - LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext, search_by_mail_filter, + LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext, search_by_everything, }; #[tracing::instrument(name = "ldap", skip(stream, db), fields(session = %stream.session))] -pub async fn ldap_handler(mut stream: LdapStream, mut db: Database) { +pub async fn ldap_handler(mut stream: LdapStream, mut db: Database) { tracing::info! { remote_addr = ?stream.remote_addr, "New client connection" @@ -44,12 +44,12 @@ pub async fn ldap_handler(mut stream: LdapStream, mut db: } /// Return true to keep the connection going, false to close it. -#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream))] -pub async fn ldap_handler_inner( +#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream, msg))] +pub async fn ldap_handler_inner( stream: &mut LdapStream, msg: LdapMsg, client_state: &mut LdapClientState, - db: &mut Database, + db: &mut Database, ) -> Result { tracing::debug!(msg = ?msg, "Received LDAP message"); match msg { @@ -93,7 +93,7 @@ pub async fn ldap_handler_inner( // TODO: ctrl for pagination ctrl: _, } => { - search_by_mail_filter(stream, db, sr, msgid).await?; + search_by_everything(stream, db, sr, msgid).await?; Ok(true) } // Unsupported message diff --git a/src/ldap/mod.rs b/src/ldap/mod.rs index d72d094..00f6174 100644 --- a/src/ldap/mod.rs +++ b/src/ldap/mod.rs @@ -1,14 +1,15 @@ +mod attr; +pub use attr::LdapAttribute; mod client_state; pub use client_state::LdapClientState; mod dn; pub use dn::{Dn, MalformedDn}; -mod filter; mod handler; mod op; pub use handler::ldap_handler; pub use op::bind::{BindDn, op_bind}; pub use op::ext::op_ext; -pub use op::search::search_by_mail_filter; +pub use op::search::search_by_everything; mod return_error; pub use return_error::LdapReturnError; mod stream; diff --git a/src/ldap/op/bind.rs b/src/ldap/op/bind.rs index 5b44c41..144efaa 100644 --- a/src/ldap/op/bind.rs +++ b/src/ldap/op/bind.rs @@ -2,7 +2,7 @@ use ldap3_proto::proto::{LdapBindCred, LdapBindRequest, LdapBindResponse, LdapOp use ldap3_proto::{LdapMsg, LdapResultCode}; use crate::db::error::BoxedError; -use crate::db::{Database, DatabaseInterface, UserRef}; +use crate::db::{Database, UserRef}; use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; #[derive(Debug)] @@ -76,9 +76,15 @@ impl BindDn { pub fn to_user_ref(&self) -> UserRef { let username = self.0.keys.get("uid").unwrap()[0].clone(); + // TODO: if we want service domains to connect over LDAP + // we need to remove this unwrap and allow a BindDn to + // not have a domain part. let domain = self.0.keys.get("dc").unwrap().join("."); - UserRef { username, domain } + UserRef { + username, + domain: Some(domain), + } } } @@ -159,9 +165,9 @@ pub async fn bind_success(stream: &mut LdapStream, msgid: i32) -> Result<(), Lda /// /// On success, returns `Ok(Some(bound_dn))`. `Ok(None)` means credentials failed, /// either because the account does not exist, or the password is wrong. -pub async fn op_bind( +pub async fn op_bind( stream: &mut LdapStream, - db: &Database, + db: &Database, req: LdapBindRequest, msgid: i32, ) -> Result, LdapStreamError> { diff --git a/src/ldap/op/search.rs b/src/ldap/op/search.rs index 3255298..0a29122 100644 --- a/src/ldap/op/search.rs +++ b/src/ldap/op/search.rs @@ -1,13 +1,14 @@ use ldap3_proto::control::LdapControl; use ldap3_proto::proto::{ - LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry, + LdapFilter, LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry, }; use ldap3_proto::{LdapMsg, LdapResultCode}; +use std::str::FromStr; + use crate::db::error::BoxedError; use crate::db::{Database, DatabaseInterface, User}; -use crate::ldap::filter::mail::{MailDomainError, MailFilter}; -use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; +use crate::ldap::{Dn, LdapAttribute, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; #[derive(Debug)] pub struct InvalidSearchDn { @@ -39,7 +40,7 @@ impl LdapReturnError for InvalidSearchDn { } #[derive(Clone, Debug)] -pub struct SearchDn(Dn); +pub struct SearchDn(#[expect(unused)] Dn); impl SearchDn { pub fn from_dn_str(input: &str) -> Result { @@ -70,7 +71,6 @@ impl SearchDn { pub enum SearchError { Db(BoxedError), InvalidDn(InvalidSearchDn), - MailDomain(MailDomainError), } impl SearchError { @@ -100,7 +100,6 @@ impl LdapReturnError for SearchError { match self { Self::Db(_e) => LdapResultCode::Unavailable, Self::InvalidDn(e) => e.code(), - Self::MailDomain(e) => e.code(), } } @@ -108,7 +107,6 @@ impl LdapReturnError for SearchError { match self { Self::Db(e) => format!("Database error: {e}"), Self::InvalidDn(e) => e.message(), - Self::MailDomain(e) => e.message(), } } } @@ -150,22 +148,42 @@ pub async fn search_success( fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry { let mut res: Vec = vec![]; - for attr in req_attrs { - if let Some(attr_value) = match attr.as_str() { - "uid" => Some(user.username.clone()), - "cn"|"mail" => Some(user.mail.clone()), - _ => { - tracing::warn!("Ignoring unknown attr in search query: {attr}"); - None + for attr_str in req_attrs { + // We keep a copy of the requested attribute so that we can answer as it was requested, + // eg. `CN` => `CN` (instead of normalizing to `cn`). + let Some(attr) = LdapAttribute::from_str(attr_str) + .inspect_err(|e| tracing::debug!("Unknown attribute in request: {e}")) + .ok() + else { + continue; + }; + + let str_values = match attr { + // TODO: should we normalize the value some more here? + LdapAttribute::Uid => vec![user.username.clone()], + // TODO: should CN be different than the mail? + // TODO: should we normalize values some more here? + LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => { + vec![user.mail.clone()] } - } { - res.push(LdapPartialAttribute { - atype: attr.clone(), - // TODO: there may be multiple values here in the future, - // eg. mailaliases - vals: vec![Vec::from(attr_value)], - }); - } + // TODO: group membership + LdapAttribute::MemberOf => vec![], + // TODO: if we introduce mail permission, we need to remove mailaccount from here + LdapAttribute::ObjectClass => vec![ + "inetOrgPerson".to_string(), + "posixAccount".to_string(), + "mailAccount".to_string(), + "person".to_string(), + ], + }; + + res.push(LdapPartialAttribute { + // Reuse the requested attribute name, not the normalized form + // we would otherwise produce. + atype: attr_str.clone(), + // LDAP response expects raw byte vec for each value + vals: str_values.into_iter().map(Vec::from).collect(), + }); } LdapSearchResultEntry { @@ -174,9 +192,9 @@ fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResult } } -pub async fn search_by_mail_filter( +pub async fn search_by_everything( stream: &mut LdapStream, - db: &Database, + db: &Database, sr: LdapSearchRequest, msgid: i32, ) -> Result<(), LdapStreamError> { @@ -188,30 +206,96 @@ pub async fn search_by_mail_filter( return Ok(()); } - let mail_filter = match MailFilter::from_search_filter(&sr.filter) { - Ok(mail_filter) => mail_filter, - Err(e) => { - SearchError::MailDomain(e) - .error_message(stream, msgid) - .await?; - return Ok(()); - } - }; - - let maybe_user = match db.get_user(&mail_filter.to_user_ref()).await { - Ok(maybe_user) => maybe_user, + let users = match search_everything(db, &sr.filter).await { + Ok(users) => users, Err(e) => { SearchError::Db(e).error_message(stream, msgid).await?; return Ok(()); } }; - if let Some(user) = maybe_user { - let entry_ctrl = (search_entry_from_user(&user, &sr.attrs), vec![]); - search_success(stream, msgid, vec![entry_ctrl]).await?; - } else { - search_success(stream, msgid, vec![]).await?; - } - + // TODO: pagination ctrl + let entries = users + .into_iter() + .map(|user| (search_entry_from_user(&user, &sr.attrs), vec![])) + .collect(); + search_success(stream, msgid, entries).await?; Ok(()) } + +// TODO: pagination +// TODO: support groups (objectclass=groupOfNames) and membership +/// A very unoptimized search algorithm for the LDAP database. +/// +/// Key information: +/// - ignores all filters that are not AND/OR/NOT/EQ (others may be added +/// if we find a valid usecase) +/// - iterates over every single entry in the database!!!!! +pub async fn search_everything(db: &Database, sr: &LdapFilter) -> Result, BoxedError> { + Ok(db + .list_all_users() + .await? + .into_iter() + .filter(|user| user_matches_filter(user, sr)) + .collect()) +} + +/// Checks if a user matches a single `LdapFilter`. +/// +/// Used recursively to see if a user matches a filter overall. +pub fn user_matches_filter(user: &User, filter: &LdapFilter) -> bool { + // TODO: we should also receive the group memberships as argument here + // so we can match that in the future + // For now, we only match mail/mailalias/uid attributes + // TODO: implement mailalias, for now it's simply mapped to the mail attribute + match filter { + LdapFilter::And(filters) => { + for sub_filter in filters { + if !user_matches_filter(user, sub_filter) { + return false; + } + } + true + } + LdapFilter::Or(filters) => { + for sub_filter in filters { + if user_matches_filter(user, sub_filter) { + return true; + } + } + false + } + LdapFilter::Not(sub_filter) => !user_matches_filter(user, sub_filter), + LdapFilter::Equality(attr, value) => LdapAttribute::from_str(attr).map_or_else( + |e| { + tracing::warn!("Unrecognized attribute, considering no match: {e}"); + false + }, + |attr| user_matches_attribute(user, &attr, value), + ), + _ => { + tracing::warn!("Unimplemented search filter, considering no match: {filter:?}"); + false + } + } +} + +// TODO: we want to support group relations here as argument soon +pub fn user_matches_attribute(user: &User, attribute: &LdapAttribute, value: &str) -> bool { + match attribute { + // TODO: should we lowercase the value here? + LdapAttribute::Uid => user.username == *value, + // TODO: should CN be different than the mail? + // TODO: should we lowercase the value here? + LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => { + user.mail == *value + } + // TODO: group membership + LdapAttribute::MemberOf => false, + LdapAttribute::ObjectClass => matches!( + // We lowercase the value here because there's no ambiguity + value.to_lowercase().as_ref(), + "inetorgperson" | "posixaccount" | "mailaccount" | "person" + ), + } +} diff --git a/src/main.rs b/src/main.rs index 1bcde2c..69487e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,23 +3,31 @@ mod cli; mod db; mod error; +#[cfg(feature = "http")] +mod http; mod ldap; mod listener; mod stream; +#[cfg(feature = "http")] +use crate::http::http_listen; use cli::CliArgs; -use db::{Database, DatabaseInterface, MemoryDatabase, User}; +use db::{Database, DatabaseInterface, FilesystemDatabase, MemoryDatabase, Role, User}; use error::GlobalError; use ldap::ldap_handler; use listener::ListenerPath; -async fn create_dummy_users(db: &mut Database) { - for domain in &["a", "b", "c"] { +async fn create_dummy_users(db: &mut Database) { + // TODO: customize admin password + if db.list_all_users().await.unwrap().is_empty() { + tracing::info!("Creating admin account with default `adminadmin` password"); db.create_user(User { - username: domain.to_string(), - domain: format!("{domain}.localhost"), + username: "admin".to_string(), + domain: None, password: "adminadmin".to_string(), - mail: format!("{domain}@{domain}.localhost"), + // TODO: what should we put here? + mail: "admin".to_string(), + role: Role::Admin, }) .await .unwrap() @@ -39,14 +47,26 @@ async fn main() -> Result<(), GlobalError> { // .init(); // tracing::subscriber::set_global_default(subscriber)?; - let listener = ListenerPath::new(&cli.listen)?.listener().await?; + let ldap_listener = ListenerPath::new(&cli.listen_ldap)?.listener().await?; - let mut db = MemoryDatabase::new(); + let mut db = if let Some(db_path) = &cli.db { + FilesystemDatabase::from_path(db_path).await.unwrap() + } else { + MemoryDatabase::new() + }; + tracing::info!("Database loaded successfully"); create_dummy_users(&mut db).await; + #[cfg(feature = "http")] + { + let http_listener = ListenerPath::new(&cli.listen_http)?.listener().await?; + let http_db = db.clone(); + tokio::spawn(http_listen(http_listener, http_db)); + } + // If the connection is None, it's because the client aborted early // so there's nothing to do about it. - while let Some(stream) = listener.accept_ldap().await? { + while let Some(stream) = ldap_listener.accept_ldap().await? { let db = db.clone(); tokio::spawn(ldap_handler(stream, db)); } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b92b6da --- /dev/null +++ b/templates/base.html @@ -0,0 +1,11 @@ + + + + LLLDAP + + + +{% block main %} +{% endblock %} + + diff --git a/templates/domain.html b/templates/domain.html new file mode 100644 index 0000000..758ad65 --- /dev/null +++ b/templates/domain.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% block main %} +
+ +

You are logged in as {{ user.username }}

+
+ Logout +
+ {% if can_create_user %} +
+

Create user

+
+ + + + +
+
+ {% endif %} +
+

Users on {{ domain.name }}

+
    + {% for user in users %} +
  • {{ user.mail }}
  • + {% endfor %} +
+
+
+{% endblock %} + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..d1f71b2 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% block main %} +
+ +

You are logged in as {{ user.username }}

+
+ Logout +
+ {% if can_create_domain %} +
+

Create domain

+
+ + +
+
+ {% endif %} +
+

Active domains you can see

+ +
+ {% if other_users %} +
+

Other users you have permission to see on your own domain

+
    + {% for user in other_users %} +
  • {{ user.mail }}
  • + {% endfor %} +
+
+ {% endif %} +
+{% endblock %} + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..82ef600 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} +{% block main %} +
+ + {% if login_error == "InvalidCredentials" %} + + {% elif login_error == "SessionInvalidated" %} + + {% endif %} +
+
+ + +
+
+ + +
+
+ +
+
+
+{% endblock %} +