commit 4105cf3f9d68ed72661645772347b51f58c40a94 Author: DyadaMorgan Date: Tue Sep 22 21:04:05 2026 +0200 Initial commit diff --git a/Cargo.lock b/Cargo.lock new file mode 100755 index 0000000..e9a6b2d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1046 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +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 = "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", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[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 = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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 = "lua-src" +version = "547.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.12+a4f56a4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f5f8fbebc7db5f671671134b9321c4b9aa9adeafccfd9a8c020ae45c6a35d0" +dependencies = [ + "bstr", + "either", + "futures-util", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", +] + +[[package]] +name = "mlua-sys" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openprivnet" +version = "0.9.8" +dependencies = [ + "chrono", + "md5", + "mlua", + "once_cell", + "regex", + "rusqlite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "tokio", + "tokio-rustls", +] + +[[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", + "smallvec", + "windows-link", +] + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[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 = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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_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_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 = "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 = "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" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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 = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[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.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.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.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.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.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.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.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.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.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 = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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 100755 index 0000000..6891d05 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "openprivnet" +version = "0.9.8" +edition = "2021" + +[[bin]] +name = "server" +path = "src/server.rs" + +[dependencies] +tokio = { version = "1", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustls-pemfile = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.31", features = ["bundled"] } +md5 = "0.7" +chrono = "0.4" +regex = "1" +once_cell = "1" +mlua = { version = "0.10", features = ["lua54", "vendored", "async", "send"] } diff --git a/src/admins.json b/src/admins.json new file mode 100755 index 0000000..400f922 --- /dev/null +++ b/src/admins.json @@ -0,0 +1,14 @@ +[ + { + "ip": "127.0.0.1", + "nick": "admin-nick", + "immunity": 999, + "prefix": "[&c&lADMIN&r] " + }, + { + "ip": "192.168.0.1", + "nick": "helper-nick", + "immunity": 998, + "prefix": "[&c&lHELPER&r] " + } +] diff --git a/src/banip_users.json b/src/banip_users.json new file mode 100755 index 0000000..0637a08 --- /dev/null +++ b/src/banip_users.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/src/channels.db b/src/channels.db new file mode 100755 index 0000000..76d7cd4 Binary files /dev/null and b/src/channels.db differ diff --git a/src/config.json b/src/config.json new file mode 100755 index 0000000..586e71c --- /dev/null +++ b/src/config.json @@ -0,0 +1,14 @@ +{ + "ip": "127.0.0.1", + "port": 12345, + "max_clients": 32, + "max_file_size": 9999, + "file_dir": "uploads", + "file_server_port": 7777, + "delete_files_on_shutdown": true, + "cert_file": "certs/server.crt", + "ca_file": "certs/ca.crt", + "server_pem": "certs/server_all.pem", + "key_file": "certs/server.key", + "welcome_text": "&2Welcome to OpenPrivNet! Type /nick and /join ." +} diff --git a/src/db.sqlite b/src/db.sqlite new file mode 100755 index 0000000..d81f907 --- /dev/null +++ b/src/db.sqlite @@ -0,0 +1,23 @@ +import sqlite3 + +def create_db(): + conn = sqlite3.connect('db.sqlite') + cursor = conn.cursor() + + # Создание таблиц + cursor.execute('''CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + public_key TEXT NOT NULL + )''') + + cursor.execute('''CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command_name TEXT NOT NULL, + description TEXT + )''') + + conn.commit() + conn.close() + +create_db() diff --git a/src/plugins.cfg b/src/plugins.cfg new file mode 100755 index 0000000..a931f44 --- /dev/null +++ b/src/plugins.cfg @@ -0,0 +1 @@ +plugins = example diff --git a/src/plugins.rs b/src/plugins.rs new file mode 100755 index 0000000..80ec64e --- /dev/null +++ b/src/plugins.rs @@ -0,0 +1,929 @@ +// OpenPrivNet — Lua Plugin System +// Аналог PluginManager + PluginContext из Python-версии. +// +// API для плагинов (Lua): +// init_plugin(ctx) — обязательная функция инициализации +// cleanup_plugin() — опциональная функция выгрузки +// +// ctx — объект PluginContext со следующими методами: +// ctx:register_command("/cmd", function(client_id, args) ... end) +// ctx:register_event("client_connected", function(client_id) ... end) +// ctx:register_event("client_disconnected", function(client_id) ... end) +// ctx:register_event("message_sent", function(client_id, msg, formatted) ... end) +// ctx:register_event("channel_joined", function(client_id, channel) ... end) +// ctx:register_event("channel_left", function(client_id, channel) ... end) +// ctx:register_event("nickname_changed", function(client_id, old_nick, new_nick) ... end) +// ctx:register_event("file_uploaded", function(client_id, filename, size, url) ... end) +// ctx:register_event("file_downloaded", function(client_id, filename, size, url) ... end) +// +// ctx:send_message(client_id, message) +// ctx:broadcast_to_channel(channel_name, message) +// ctx:get_clients_in_channel(channel_name) -> table of client_id +// ctx:get_all_channels() -> table of channel names +// ctx:get_client_info(client_id) -> {nickname, channel, prefix, ip} +// ctx:get_client_by_nickname(nickname) -> client_id or nil +// ctx:create_channel(name) -> result string +// ctx:delete_channel(name) -> result string +// ctx:log(message) -> print with plugin prefix +// +// Безопасность (sandbox): +// - Заблокированы: os.execute, io.popen, load, loadfile, dofile, require, +// rawget, rawset, rawequal, rawlen, debug, package +// - Файловый I/O ограничен папкой plugins/plugins_data// +// - Доступ к certs/ заблокирован на уровне ядра sandbox'а +// - Нет доступа к сети напрямую (нет socket, нет ffi) +// - Лимит инструкций на один вызов (защита от бесконечных циклов) +// - Лимит памяти на Lua-состояние + +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +use mlua::prelude::*; +use tokio::sync::mpsc; + +use crate::{ClientId, SharedState}; + +// ─── Константы безопасности ─────────────────────────────────────────────────── + +/// Максимум Lua-инструкций за один вызов (защита от while true do end) +const LUA_INSTRUCTION_LIMIT: u32 = 1_000_000; + +/// Корневая директория для файлов плагинов +const PLUGIN_DATA_ROOT: &str = "plugins/plugins_data"; + +/// Запрещённые пути (блокируются на уровне ядра) +const BLOCKED_PATH_PREFIXES: &[&str] = &[ + "certs/", + "certs\\", + "../certs", + "..\\certs", +]; + +// ─── Типы событий ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PluginEvent { + ClientConnected, + ClientDisconnected, + MessageSent, + ChannelJoined, + ChannelLeft, + NicknameChanged, + FileUploaded, + FileDownloaded, +} + +impl PluginEvent { + fn from_str(s: &str) -> Option { + match s { + "client_connected" => Some(Self::ClientConnected), + "client_disconnected" => Some(Self::ClientDisconnected), + "message_sent" => Some(Self::MessageSent), + "channel_joined" => Some(Self::ChannelJoined), + "channel_left" => Some(Self::ChannelLeft), + "nickname_changed" => Some(Self::NicknameChanged), + "file_uploaded" => Some(Self::FileUploaded), + "file_downloaded" => Some(Self::FileDownloaded), + _ => None, + } + } + fn as_str(&self) -> &'static str { + match self { + Self::ClientConnected => "client_connected", + Self::ClientDisconnected => "client_disconnected", + Self::MessageSent => "message_sent", + Self::ChannelJoined => "channel_joined", + Self::ChannelLeft => "channel_left", + Self::NicknameChanged => "nickname_changed", + Self::FileUploaded => "file_uploaded", + Self::FileDownloaded => "file_downloaded", + } + } +} + +// ─── Аргументы событий ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum EventArgs { + ClientConnected { client_id: ClientId }, + ClientDisconnected { client_id: ClientId }, + MessageSent { client_id: ClientId, msg: String, formatted: String }, + ChannelJoined { client_id: ClientId, channel: String }, + ChannelLeft { client_id: ClientId, channel: String }, + NicknameChanged { client_id: ClientId, old_nick: String, new_nick: String }, + FileUploaded { client_id: ClientId, filename: String, size: u64, url: String }, + FileDownloaded { client_id: ClientId, filename: String, size: u64, url: String }, +} + +// ─── Канал для операций с сервером из Lua ───────────────────────────────────── +// Lua работает синхронно внутри mlua, поэтому для отправки сообщений клиентам +// используем mpsc — плагин кладёт задачи в очередь, tokio выполняет их асинхронно. + +#[derive(Debug)] +#[allow(dead_code)] +pub enum ServerOp { + SendToClient { client_id: ClientId, message: String }, + BroadcastChannel { channel: String, message: String }, + CreateChannel { name: String, reply: mpsc::Sender }, + DeleteChannel { name: String, reply: mpsc::Sender }, +} + +// ─── Состояние одного плагина ───────────────────────────────────────────────── + +struct PluginState { + name: String, + loaded_at: f64, + /// Lua VM для этого плагина (изолирован от других плагинов) + lua: Lua, + /// Зарегистрированные команды: "/cmd" -> lua function key в registry + commands: Vec, + /// Зарегистрированные события: event -> lua function key в registry + event_handlers: HashMap, +} + +// ─── PluginManager ──────────────────────────────────────────────────────────── + +pub struct PluginManager { + plugins: HashMap, + /// Команды: "/cmd" -> plugin_name + plugin_commands: HashMap, + /// Канал для передачи серверных операций из Lua → Tokio + server_op_tx: mpsc::UnboundedSender, + /// Shared state для чтения (nickname lookup, channels и т.д.) + state: SharedState, +} + +impl PluginManager { + pub fn new(state: SharedState, server_op_tx: mpsc::UnboundedSender) -> Self { + Self { + plugins: HashMap::new(), + plugin_commands: HashMap::new(), + server_op_tx, + state, + } + } + + // ── Загрузка плагинов из plugins.cfg ────────────────────────────────────── + + pub fn load_plugins_from_config(&mut self) { + if !Path::new("plugins.cfg").exists() { + println!("[PluginManager] plugins.cfg not found, no plugins loaded."); + return; + } + + let content = match fs::read_to_string("plugins.cfg") { + Ok(c) => c, + Err(e) => { eprintln!("[PluginManager] Error reading plugins.cfg: {}", e); return; } + }; + + let mut plugin_names: Vec = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with("plugins") { + if let Some((_, rhs)) = line.split_once('=') { + plugin_names = rhs.split_whitespace().map(|s| s.to_string()).collect(); + break; + } + } + } + + println!("[PluginManager] Loading plugins: {:?}", plugin_names); + let mut loaded = 0; + let total = plugin_names.len(); + for name in plugin_names { + if self.load_plugin(&name) { loaded += 1; } + } + println!("[PluginManager] Loaded {}/{} plugins", loaded, total); + } + + // ── Загрузка одного плагина ──────────────────────────────────────────────── + + pub fn load_plugin(&mut self, plugin_name: &str) -> bool { + let path = format!("plugins/{}.lua", plugin_name); + if !Path::new(&path).exists() { + eprintln!("[PluginManager] Plugin file not found: {}", path); + return false; + } + + let code = match fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { eprintln!("[PluginManager] Cannot read {}: {}", path, e); return false; } + }; + + // Создаём изолированную Lua VM + let lua = match Lua::new_with( + LuaStdLib::TABLE | LuaStdLib::STRING | LuaStdLib::MATH | LuaStdLib::OS, + LuaOptions::default(), + ) { + Ok(l) => l, + Err(e) => { eprintln!("[PluginManager] Failed to create Lua state for '{}': {}", plugin_name, e); return false; } + }; + + // Применяем sandbox + if let Err(e) = Self::apply_sandbox(&lua, plugin_name, &self.server_op_tx, self.state.clone()) { + eprintln!("[PluginManager] Failed to sandbox '{}': {}", plugin_name, e); + return false; + } + + // Создаём папку данных плагина + let data_dir = format!("{}/{}", PLUGIN_DATA_ROOT, plugin_name); + let _ = fs::create_dir_all(&data_dir); + + // Устанавливаем лимит инструкций + let _ = lua.set_hook(LuaHookTriggers::default().every_nth_instruction(LUA_INSTRUCTION_LIMIT), |_lua, _debug| { + Err(LuaError::RuntimeError( + "Plugin exceeded instruction limit (possible infinite loop)".into() + )) + }); + + // Выполняем код плагина + if let Err(e) = lua.load(&code).set_name(plugin_name).exec() { + eprintln!("[PluginManager] Error executing plugin '{}': {}", plugin_name, e); + return false; + } + + // Сбрасываем хук — init_plugin может быть длинным при первом запуске + lua.remove_hook(); + + // Ищем init_plugin + let init_fn: LuaFunction = match lua.globals().get("init_plugin") { + Ok(f) => f, + Err(_) => { + eprintln!("[PluginManager] Plugin '{}' has no init_plugin function", plugin_name); + return false; + } + }; + + // Создаём PluginContext как Lua userdata/table + let ctx = match Self::make_plugin_context(&lua, plugin_name, &self.server_op_tx, self.state.clone()) { + Ok(c) => c, + Err(e) => { eprintln!("[PluginManager] Failed to create context for '{}': {}", plugin_name, e); return false; } + }; + + // Вызываем init_plugin(ctx) + let result: LuaResult = init_fn.call(ctx); + match result { + Err(e) => { + eprintln!("[PluginManager] Error in init_plugin of '{}': {}", plugin_name, e); + return false; + } + Ok(LuaValue::Boolean(false)) => { + eprintln!("[PluginManager] Plugin '{}' init_plugin returned false", plugin_name); + return false; + } + _ => {} + } + + // Читаем зарегистрированные команды и события из специальной таблицы + // (они были записаны в _G._plugin_registry во время init_plugin) + let (commands, event_handlers) = Self::collect_registry(&lua, plugin_name); + + // Регистрируем команды в глобальной таблице + for cmd in &commands { + if self.plugin_commands.contains_key(cmd) { + println!("[PluginManager] Warning: command {} already registered, overriding", cmd); + } + self.plugin_commands.insert(cmd.clone(), plugin_name.to_string()); + println!("[+] Command {} registered by plugin {}", cmd, plugin_name); + } + + let loaded_at = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + self.plugins.insert(plugin_name.to_string(), PluginState { + name: plugin_name.to_string(), + loaded_at, + lua, + commands, + event_handlers, + }); + + // Восстанавливаем лимит инструкций для обычных вызовов + if let Some(ps) = self.plugins.get(&plugin_name.to_string()) { + ps.lua.set_hook(LuaHookTriggers::default().every_nth_instruction(LUA_INSTRUCTION_LIMIT), |_lua, _debug| { + Err(LuaError::RuntimeError("Plugin exceeded instruction limit".into())) + }); + } + + println!("[+] Plugin '{}' loaded successfully", plugin_name); + true + } + + // ── Выгрузка плагина ─────────────────────────────────────────────────────── + + pub fn unload_plugin(&mut self, plugin_name: &str) -> bool { + let ps = match self.plugins.remove(plugin_name) { + Some(p) => p, + None => return false, + }; + + // Вызываем cleanup_plugin() если есть + if let Ok(cleanup) = ps.lua.globals().get::("cleanup_plugin") { + if let Err(e) = cleanup.call::<()>(()) { + eprintln!("[PluginManager] Error in cleanup_plugin of '{}': {}", plugin_name, e); + } + } + + // Удаляем команды + for cmd in &ps.commands { + self.plugin_commands.remove(cmd); + } + + // Lua VM уничтожается вместе с ps + + println!("[+] Plugin '{}' unloaded", plugin_name); + true + } + + pub fn reload_plugin(&mut self, plugin_name: &str) -> bool { + println!("[PluginManager] Reloading plugin '{}'", plugin_name); + self.unload_plugin(plugin_name); + self.load_plugin(plugin_name) + } + + pub fn reload_all_plugins(&mut self) { + println!("[PluginManager] Reloading all plugins..."); + let names: Vec = self.plugins.keys().cloned().collect(); + for name in names { + self.unload_plugin(&name); + } + self.load_plugins_from_config(); + } + + // ── Информация о плагинах ───────────────────────────────────────────────── + + pub fn get_plugin_info(&self) -> Vec { + self.plugins.values().map(|ps| { + let ts = chrono::DateTime::from_timestamp(ps.loaded_at as i64, 0) + .unwrap_or_default() + .format("%H:%M:%S") + .to_string(); + PluginInfo { + name: ps.name.clone(), + loaded_at: ts, + commands: ps.commands.clone(), + } + }).collect() + } + + pub fn has_command(&self, cmd: &str) -> bool { + self.plugin_commands.contains_key(cmd) + } + + pub fn get_all_plugin_commands(&self) -> Vec<(String, String)> { + self.plugin_commands.iter() + .map(|(cmd, plugin)| (cmd.clone(), plugin.clone())) + .collect() + } + + // ── Вызов команды плагина ───────────────────────────────────────────────── + + pub fn dispatch_command(&self, command: &str, client_id: ClientId, args: &str) -> Option { + let plugin_name = self.plugin_commands.get(command)?; + let ps = self.plugins.get(plugin_name)?; + + // Читаем функцию из реестра плагина + let registry: LuaTable = match ps.lua.globals().get("_plugin_registry") { + Ok(t) => t, + Err(_) => return Some(format!("Plugin '{}' registry missing", plugin_name)), + }; + let cmds: LuaTable = match registry.get("commands") { + Ok(t) => t, + Err(_) => return Some(format!("Plugin '{}' commands table missing", plugin_name)), + }; + let handler: LuaFunction = match cmds.get(command.to_string()) { + Ok(f) => f, + Err(_) => return Some(format!("Command handler not found for {}", command)), + }; + + match handler.call::((client_id as LuaInteger, args.to_string())) { + Ok(LuaValue::String(s)) => Some(s.to_string_lossy().to_string()), + Ok(LuaValue::Nil) | Ok(LuaValue::Boolean(false)) => None, + Ok(_) => None, + Err(e) => { + eprintln!("[PluginManager] Error in command '{}' from '{}': {}", command, plugin_name, e); + Some(format!("Plugin error: {}", e)) + } + } + } + + // ── Вызов события ───────────────────────────────────────────────────────── + + pub fn trigger_event(&self, event: &EventArgs) { + let event_type = match event { + EventArgs::ClientConnected { .. } => PluginEvent::ClientConnected, + EventArgs::ClientDisconnected { .. } => PluginEvent::ClientDisconnected, + EventArgs::MessageSent { .. } => PluginEvent::MessageSent, + EventArgs::ChannelJoined { .. } => PluginEvent::ChannelJoined, + EventArgs::ChannelLeft { .. } => PluginEvent::ChannelLeft, + EventArgs::NicknameChanged { .. } => PluginEvent::NicknameChanged, + EventArgs::FileUploaded { .. } => PluginEvent::FileUploaded, + EventArgs::FileDownloaded { .. } => PluginEvent::FileDownloaded, + }; + + for ps in self.plugins.values() { + let handler_key = match ps.event_handlers.get(&event_type) { + Some(k) => k.clone(), + None => continue, + }; + + let registry: LuaTable = match ps.lua.globals().get("_plugin_registry") { + Ok(t) => t, + Err(_) => continue, + }; + let events: LuaTable = match registry.get("events") { + Ok(t) => t, + Err(_) => continue, + }; + let handler: LuaFunction = match events.get(handler_key) { + Ok(f) => f, + Err(_) => continue, + }; + + let result = match event { + EventArgs::ClientConnected { client_id } => + handler.call::<()>(*client_id as LuaInteger), + EventArgs::ClientDisconnected { client_id } => + handler.call::<()>(*client_id as LuaInteger), + EventArgs::MessageSent { client_id, msg, formatted } => + handler.call::<()>((*client_id as LuaInteger, msg.clone(), formatted.clone())), + EventArgs::ChannelJoined { client_id, channel } => + handler.call::<()>((*client_id as LuaInteger, channel.clone())), + EventArgs::ChannelLeft { client_id, channel } => + handler.call::<()>((*client_id as LuaInteger, channel.clone())), + EventArgs::NicknameChanged { client_id, old_nick, new_nick } => + handler.call::<()>((*client_id as LuaInteger, old_nick.clone(), new_nick.clone())), + EventArgs::FileUploaded { client_id, filename, size, url } => + handler.call::<()>((*client_id as LuaInteger, filename.clone(), *size as LuaInteger, url.clone())), + EventArgs::FileDownloaded { client_id, filename, size, url } => + handler.call::<()>((*client_id as LuaInteger, filename.clone(), *size as LuaInteger, url.clone())), + }; + + if let Err(e) = result { + eprintln!("[PluginManager] Error in event '{}' handler of '{}': {}", + event_type.as_str(), ps.name, e); + } + } + } + + // ── Sandbox: убираем опасные функции и настраиваем безопасный I/O ───────── + + fn apply_sandbox( + lua: &Lua, + plugin_name: &str, + _server_op_tx: &mpsc::UnboundedSender, // добавлен _ + _state: SharedState, // добавлен _ + ) -> LuaResult<()> { + let globals = lua.globals(); + + // 1. Убираем опасные глобальные функции + let dangerous: &[&str] = &[ + "load", "loadfile", "dofile", "require", + "rawget", "rawset", "rawequal", "rawlen", + "collectgarbage", "newproxy", + ]; + for name in dangerous { + globals.set(*name, LuaValue::Nil)?; + } + + // 2. Убираем опасные модули полностью + globals.set("debug", LuaValue::Nil)?; + globals.set("package", LuaValue::Nil)?; + globals.set("io", LuaValue::Nil)?; // заменим ниже безопасной версией + globals.set("os", LuaValue::Nil)?; // заменим ниже безопасной версией + + // 3. Безопасный os — только time, clock, date (без execute, exit, getenv и т.д.) + let safe_os = lua.create_table()?; + { + let lua_ref = lua; + safe_os.set("time", lua_ref.create_function(|_, ()| { + Ok(SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as LuaInteger) + })?)?; + safe_os.set("clock", lua_ref.create_function(|_, ()| { + Ok(SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64()) + })?)?; + safe_os.set("date", lua_ref.create_function(|_, fmt: Option| { + let fmt = fmt.unwrap_or_else(|| "%Y-%m-%d %H:%M:%S".to_string()); + Ok(chrono::Local::now().format(&fmt).to_string()) + })?)?; + } + globals.set("os", safe_os)?; + + // 4. Безопасный io — только файлы внутри plugins/plugins_data// + let data_root = format!("{}/{}", PLUGIN_DATA_ROOT, plugin_name); + + let safe_io = lua.create_table()?; + { + // io.open(path, mode) — только внутри data_dir + let dr = data_root.clone(); + safe_io.set("open", lua.create_function(move |lua_ctx, (rel_path, mode): (String, Option)| { + // Проверяем путь + check_path_safety(&rel_path)?; + let full_path = Path::new(&dr).join(&rel_path); + // Нормализуем и проверяем что не вышли за пределы data_dir + let canonical_base = fs::canonicalize(&dr) + .map_err(|e| LuaError::RuntimeError(format!("Data dir error: {}", e)))?; + // Для несуществующего файла проверяем родителя + let canonical_full = if full_path.exists() { + fs::canonicalize(&full_path) + .map_err(|e| LuaError::RuntimeError(format!("Path error: {}", e)))? + } else { + let parent = full_path.parent().unwrap_or(&full_path); + let canon_parent = fs::canonicalize(parent) + .map_err(|e| LuaError::RuntimeError(format!("Path error: {}", e)))?; + canon_parent.join(full_path.file_name().unwrap_or_default()) + }; + + if !canonical_full.starts_with(&canonical_base) { + return Err(LuaError::RuntimeError( + "Access denied: path is outside plugin data directory".into() + )); + } + + let mode_str = mode.as_deref().unwrap_or("r"); + let file_handle = lua_ctx.create_table()?; + + match mode_str { + "r" | "rb" => { + let content = fs::read(&canonical_full) + .map_err(|e| LuaError::RuntimeError(format!("Cannot read file: {}", e)))?; + if mode_str == "rb" { + file_handle.set("content", lua_ctx.create_string(&content)?)?; + } else { + let text = String::from_utf8_lossy(&content).to_string(); + file_handle.set("content", text)?; + } + file_handle.set("pos", 0i64)?; + file_handle.set("mode", "r")?; + // file:read(fmt) — только "*a" и "*l" + let content_clone = String::from_utf8_lossy( + &fs::read(&canonical_full).unwrap_or_default() + ).to_string(); + file_handle.set("read", lua_ctx.create_function(move |_, (_, fmt): (LuaValue, Option)| { + match fmt.as_deref().unwrap_or("*l") { + "*a" | "*all" => Ok(Some(content_clone.clone())), + _ => Ok(None), + } + })?)?; + file_handle.set("close", lua_ctx.create_function(|_, _: LuaValue| Ok(()))?)?; + } + "w" | "wb" | "a" | "ab" => { + let path_for_write = canonical_full.clone(); + let append = mode_str.starts_with('a'); + file_handle.set("mode", mode_str)?; + file_handle.set("_path", canonical_full.to_string_lossy().to_string())?; + file_handle.set("_buf", "")?; + file_handle.set("write", lua_ctx.create_function(move |_, (tbl, data): (LuaTable, String)| { + let cur: String = tbl.get("_buf").unwrap_or_default(); + tbl.set("_buf", cur + &data)?; + Ok(()) + })?)?; + let path_for_close = path_for_write.clone(); + file_handle.set("close", lua_ctx.create_function(move |_, tbl: LuaTable| { + let buf: String = tbl.get("_buf").unwrap_or_default(); + if append { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true).create(true).open(&path_for_close) + .map_err(|e| LuaError::RuntimeError(e.to_string()))?; + f.write_all(buf.as_bytes()) + .map_err(|e| LuaError::RuntimeError(e.to_string()))?; + } else { + fs::write(&path_for_close, buf.as_bytes()) + .map_err(|e| LuaError::RuntimeError(e.to_string()))?; + } + Ok(()) + })?)?; + } + _ => { + return Err(LuaError::RuntimeError(format!("Unsupported file mode: {}", mode_str))); + } + } + + Ok(LuaValue::Table(file_handle)) + })?)?; + + // io.lines(path) — читает файл, возвращает таблицу строк + let dr2 = data_root.clone(); + safe_io.set("lines", lua.create_function(move |lua_ctx, rel_path: String| { + check_path_safety(&rel_path)?; + let full_path = Path::new(&dr2).join(&rel_path); + let canonical_base = fs::canonicalize(&dr2) + .map_err(|e| LuaError::RuntimeError(format!("Data dir error: {}", e)))?; + let canonical_full = fs::canonicalize(&full_path) + .map_err(|e| LuaError::RuntimeError(format!("Path error: {}", e)))?; + if !canonical_full.starts_with(&canonical_base) { + return Err(LuaError::RuntimeError("Access denied".into())); + } + let content = fs::read_to_string(&canonical_full) + .map_err(|e| LuaError::RuntimeError(e.to_string()))?; + let tbl = lua_ctx.create_table()?; + for (i, line) in content.lines().enumerate() { + tbl.set(i + 1, line.to_string())?; + } + Ok(tbl) + })?)?; + } + globals.set("io", safe_io)?; + + // 5. Инициализируем реестр команд и событий (пишется из ctx методов) + let registry = lua.create_table()?; + registry.set("commands", lua.create_table()?)?; + registry.set("events", lua.create_table()?)?; + globals.set("_plugin_registry", registry)?; + + Ok(()) + } + + // ── Создаём PluginContext для Lua ────────────────────────────────────────── + + fn make_plugin_context( + lua: &Lua, + plugin_name: &str, + server_op_tx: &mpsc::UnboundedSender, + state: SharedState, + ) -> LuaResult { + let ctx = lua.create_table()?; + let pname = plugin_name.to_string(); + + // ctx:register_command("/cmd", function(client_id, args) ... end) + { + let pn = pname.clone(); + ctx.set("register_command", lua.create_function(move |lua_ctx, (_, cmd, handler): (LuaValue, String, LuaFunction)| { + if !cmd.starts_with('/') { + return Err(LuaError::RuntimeError("Command must start with '/'".into())); + } + // Валидация имени команды + if cmd.len() > 32 || !cmd.chars().all(|c| c.is_alphanumeric() || c == '/' || c == '_') { + return Err(LuaError::RuntimeError("Invalid command name".into())); + } + let registry: LuaTable = lua_ctx.globals().get("_plugin_registry")?; + let cmds: LuaTable = registry.get("commands")?; + if cmds.contains_key(cmd.clone())? { + eprintln!("[Plugin:{}] Warning: command {} already registered, overriding", pn, cmd); + } + cmds.set(cmd.clone(), handler)?; + println!("[+] Command {} registered by plugin {}", cmd, pn); + Ok(()) + })?)?; + } + + // ctx:register_event("event_name", function(...) ... end) + { + let pn = pname.clone(); + ctx.set("register_event", lua.create_function(move |lua_ctx, (_, event_name, handler): (LuaValue, String, LuaFunction)| { + if PluginEvent::from_str(&event_name).is_none() { + return Err(LuaError::RuntimeError(format!("Unknown event: {}", event_name))); + } + let registry: LuaTable = lua_ctx.globals().get("_plugin_registry")?; + let events: LuaTable = registry.get("events")?; + events.set(event_name.clone(), handler)?; + println!("[+] Event '{}' registered by plugin {}", event_name, pn); + Ok(()) + })?)?; + } + + // ctx:send_message(client_id, message) + { + let tx = server_op_tx.clone(); + ctx.set("send_message", lua.create_function(move |_, (_, client_id, message): (LuaValue, LuaInteger, String)| { + let _ = tx.send(ServerOp::SendToClient { client_id: client_id as ClientId, message }); + Ok(()) + })?)?; + } + + // ctx:broadcast_to_channel(channel_name, message) + { + let tx = server_op_tx.clone(); + ctx.set("broadcast_to_channel", lua.create_function(move |_, (_, channel, message): (LuaValue, String, String)| { + let _ = tx.send(ServerOp::BroadcastChannel { channel, message }); + Ok(()) + })?)?; + } + + // ctx:get_client_info(client_id) -> {nickname, channel, prefix, ip} + { + let st = state.clone(); + ctx.set("get_client_info", lua.create_function(move |lua_ctx, (_, client_id): (LuaValue, LuaInteger)| { + let state = tokio::task::block_in_place(|| st.blocking_read()); + let info = lua_ctx.create_table()?; + if let Some(arc) = state.clients.get(&(client_id as ClientId)) { + if let Ok(c) = arc.try_lock() { + info.set("nickname", c.nickname.clone().unwrap_or_default())?; + info.set("channel", c.channel.clone().unwrap_or_default())?; + info.set("prefix", c.prefix.clone())?; + info.set("ip", c.addr.ip().to_string())?; + } + } + Ok(info) + })?)?; + } + + // ctx:get_clients_in_channel(channel_name) -> table of client_id + { + let st = state.clone(); + ctx.set("get_clients_in_channel", lua.create_function(move |lua_ctx, (_, channel): (LuaValue, String)| { + let state = tokio::task::block_in_place(|| st.blocking_read()); + let tbl = lua_ctx.create_table()?; + if let Some(ids) = state.channels.get(&channel) { + for (i, &id) in ids.iter().enumerate() { + tbl.set(i + 1, id as LuaInteger)?; + } + } + Ok(tbl) + })?)?; + } + + // ctx:get_all_channels() -> table of channel_name strings + { + let st = state.clone(); + ctx.set("get_all_channels", lua.create_function(move |lua_ctx, _: LuaValue| { + let state = tokio::task::block_in_place(|| st.blocking_read()); + let tbl = lua_ctx.create_table()?; + for (i, name) in state.channels.keys().enumerate() { + tbl.set(i + 1, name.clone())?; + } + Ok(tbl) + })?)?; + } + + // ctx:get_client_by_nickname(nickname) -> client_id or nil + { + let st = state.clone(); + ctx.set("get_client_by_nickname", lua.create_function(move |_, (_, nickname): (LuaValue, String)| { + let state = tokio::task::block_in_place(|| st.blocking_read()); + for (&id, arc) in &state.clients { + if let Ok(c) = arc.try_lock() { + if c.nickname.as_deref().map(|n| n.to_lowercase()) == Some(nickname.to_lowercase()) { + return Ok(LuaValue::Integer(id as LuaInteger)); + } + } + } + Ok(LuaValue::Nil) + })?)?; + } + + // ctx:create_channel(name) -> result_string + { + let tx = server_op_tx.clone(); + ctx.set("create_channel", lua.create_function(move |_, (_, name): (LuaValue, String)| { + let (reply_tx, mut reply_rx) = mpsc::channel::(1); + let _ = tx.send(ServerOp::CreateChannel { name, reply: reply_tx }); + // Синхронно ждём ответ (блокируем поток Lua, который и так не async) + let result = tokio::task::block_in_place(|| reply_rx.blocking_recv()).unwrap_or_else(|| "No response".to_string()); + Ok(result) + })?)?; + } + + // ctx:delete_channel(name) -> result_string + { + let tx = server_op_tx.clone(); + ctx.set("delete_channel", lua.create_function(move |_, (_, name): (LuaValue, String)| { + let (reply_tx, mut reply_rx) = mpsc::channel::(1); + let _ = tx.send(ServerOp::DeleteChannel { name, reply: reply_tx }); + let result = tokio::task::block_in_place(|| reply_rx.blocking_recv()).unwrap_or_else(|| "No response".to_string()); + Ok(result) + })?)?; + } + + // ctx:log(message) — вывод с префиксом плагина + { + let pn = pname.clone(); + ctx.set("log", lua.create_function(move |_, (_, msg): (LuaValue, String)| { + println!("[Plugin:{}] {}", pn, msg); + Ok(()) + })?)?; + } + + // ctx.name — имя плагина (read-only строка) + ctx.set("name", pname.clone())?; + + // Все методы уже установлены напрямую в ctx через ctx.set(...), + // поэтому метатаблица не нужна — ctx:method() работает без неё. + + Ok(ctx) + } + + // ── Собираем зарегистрированные команды и события из реестра ────────────── + + fn collect_registry(lua: &Lua, _plugin_name: &str) -> (Vec, HashMap) { + let mut commands = Vec::new(); + let mut event_handlers = HashMap::new(); + + let registry: LuaTable = match lua.globals().get("_plugin_registry") { + Ok(t) => t, + Err(_) => return (commands, event_handlers), + }; + + // Команды + if let Ok(cmds) = registry.get::("commands") { + for pair in cmds.pairs::() { + if let Ok((cmd, _)) = pair { + commands.push(cmd); + } + } + } + + // События + if let Ok(events) = registry.get::("events") { + for pair in events.pairs::() { + if let Ok((event_name, _)) = pair { + if let Some(ev) = PluginEvent::from_str(&event_name) { + event_handlers.insert(ev, event_name); + } + } + } + } + + (commands, event_handlers) + } +} + +// ─── Проверка безопасности пути ─────────────────────────────────────────────── + +fn check_path_safety(path: &str) -> LuaResult<()> { + // Запрещаем абсолютные пути + if path.starts_with('/') || path.starts_with('\\') { + return Err(LuaError::RuntimeError("Absolute paths are not allowed".into())); + } + // Запрещаем path traversal + if path.contains("..") { + return Err(LuaError::RuntimeError("Path traversal ('..') is not allowed".into())); + } + // Запрещаем доступ к сертификатам + for blocked in BLOCKED_PATH_PREFIXES { + if path.to_lowercase().contains(blocked) { + return Err(LuaError::RuntimeError("Access to certificate files is forbidden".into())); + } + } + // Запрещаем NULL-байты + if path.contains('\0') { + return Err(LuaError::RuntimeError("Invalid path".into())); + } + Ok(()) +} + +// ─── Публичные типы ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct PluginInfo { + pub name: String, + pub loaded_at: String, + pub commands: Vec, +} + +// ─── Задача-обработчик ServerOp (запускается в tokio) ───────────────────────── + +pub async fn run_server_op_handler( + mut rx: mpsc::UnboundedReceiver, + state: SharedState, +) { + while let Some(op) = rx.recv().await { + match op { + ServerOp::SendToClient { client_id, message } => { + let state = state.read().await; + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(c) = arc.try_lock() { + let _ = c.tx.try_send(message); + } + } + } + ServerOp::BroadcastChannel { channel, message } => { + let state = state.read().await; + if let Some(ids) = state.channels.get(&channel) { + for &id in ids { + if let Some(arc) = state.clients.get(&id) { + if let Ok(c) = arc.try_lock() { + let _ = c.tx.try_send(message.clone()); + } + } + } + } + } + ServerOp::CreateChannel { name, reply } => { + let result = { + let mut state = state.write().await; + crate::create_channel(&mut state, &name) + }; + let _ = reply.send(result).await; + } + ServerOp::DeleteChannel { name, reply } => { + let result = { + let mut state = state.write().await; + crate::delete_channel(&mut state, &name) + }; + let _ = reply.send(result).await; + } + } + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100755 index 0000000..55d77bd --- /dev/null +++ b/src/server.rs @@ -0,0 +1,2197 @@ +// OpenPrivNet Server — Rust Edition +// Version: 0.9.8-11b + +mod plugins; + +use std::collections::HashMap; +use std::fs; +use std::io::{BufReader}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use chrono::Local; +use once_cell::sync::Lazy; +use regex::Regex; +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::Duration; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls::{self, ServerConfig}; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; + +use plugins::{PluginManager, EventArgs, ServerOp}; + +const SERVER_VERSION: &str = "0.9.8-17b"; +const WARN_LIMIT: u32 = 3; +const IP_CONNECTION_LIMIT: u32 = 5; +const CLEANUP_INTERVAL_SECS: u64 = 300; +const TRANSFER_MAX_AGE_SECS: u64 = 3600; +const FILE_CHUNK_SIZE: usize = 8192; + +// ─── Stream multiplexing ────────────────────────────────────────────────────── +// Frame format: [1 byte: stream type][4 bytes: payload length BE][N bytes: payload] + +const STREAM_CHAT: u8 = 0x01; // CH — chat messages & commands +const STREAM_FILE: u8 = 0x02; // F — file transfer (binary chunks) +const STREAM_SYS: u8 = 0x03; // SYS — system (ping/pong, meta) + +#[derive(Debug, PartialEq)] +enum StreamKind { + Chat, + File, + System, + Unknown(u8), +} + +impl From for StreamKind { + fn from(b: u8) -> Self { + match b { + STREAM_CHAT => StreamKind::Chat, + STREAM_FILE => StreamKind::File, + STREAM_SYS => StreamKind::System, + x => StreamKind::Unknown(x), + } + } +} + +// ─── Debug output macro ─────────────────────────────────────────────────────── + +#[allow(unused_macros)] +macro_rules! dbg_log { + ($ctx:expr, $($arg:tt)*) => { + let ts = Local::now().format("%Y-%m-%d %H:%M:%S"); + println!("[DBG][{}][{}] {}", ts, $ctx, format!($($arg)*)); + }; +} + +// ─── Configuration ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +struct Config { + ip: String, + port: u16, + #[serde(default = "default_file_server_port")] + file_server_port: u16, + #[serde(default = "default_file_dir")] + file_dir: String, + #[serde(default)] + delete_files_on_shutdown: bool, + #[serde(default = "default_true")] + enable_file_server: bool, + #[serde(default = "default_true")] + require_client_cert: bool, + #[serde(default = "default_cert_file")] + cert_file: String, + #[serde(default = "default_key_file")] + key_file: String, + #[serde(default = "default_ca_file")] + ca_file: String, + #[serde(default = "default_server_pem")] + server_pem: String, + #[serde(default = "default_max_file_size")] + max_file_size: u64, + #[serde(default = "default_welcome")] + welcome_text: String, +} + +fn default_true() -> bool { true } +fn default_file_server_port() -> u16 { 7777 } +fn default_file_dir() -> String { "uploads".into() } +fn default_cert_file() -> String { "certs/server.crt".into() } +fn default_key_file() -> String { "certs/server.key".into() } +fn default_ca_file() -> String { "certs/ca.crt".into() } +fn default_server_pem() -> String { "certs/server_all.pem".into() } +fn default_max_file_size() -> u64 { 1 } +fn default_welcome() -> String { "Welcome to OpenPrivNet!".into() } + +fn load_config() -> Config { + let data = fs::read_to_string("config.json") + .expect("[!] config.json not found"); + serde_json::from_str(&data).expect("[!] Failed to parse config.json") +} + +// ─── Data structures ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BanEntry { + ip: String, + nick: String, + reason: String, + time: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AdminEntry { + ip: String, + nick: String, + prefix: String, + #[serde(default)] + immunity: u32, +} + +/// File transfer metadata +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct TransferInfo { + filename: String, + channel: String, // used when checking client access to the file + file_path: PathBuf, + created: f64, +} + +// ─── Client (shared descriptor) ─────────────────────────────────────────────── + +type ClientId = u64; + +#[derive(Debug)] +struct ClientState { + #[allow(dead_code)] + id: ClientId, + addr: SocketAddr, + nickname: Option, + channel: Option, + prefix: String, + active: bool, + tx: tokio::sync::mpsc::Sender, + last_activity: std::time::Instant, +} + +impl ClientState { + fn new(id: ClientId, addr: SocketAddr, tx: tokio::sync::mpsc::Sender) -> Self { + Self { + id, + addr, + nickname: None, + channel: None, + prefix: String::new(), + active: true, + tx, + last_activity: std::time::Instant::now(), + } + } +} + +// ─── Global server state ────────────────────────────────────────────────────── + +struct ServerState { + clients: HashMap>>, + channels: HashMap>, + banned_ips: Vec, + warn_counts: HashMap, + connections_by_ip: HashMap, + admins: Vec, + active_transfers: HashMap, + next_id: ClientId, + ip_to_client: HashMap, +} + +impl ServerState { + fn new() -> Self { + let banned_ips = Self::load_json_file("banip_users.json").unwrap_or_default(); + let warn_counts = Self::load_json_file("warn_counts.json").unwrap_or_default(); + let admins = Self::load_json_file("admins.json").unwrap_or_default(); + + Self { + clients: HashMap::new(), + channels: HashMap::new(), + banned_ips, + warn_counts, + connections_by_ip: HashMap::new(), + admins, + active_transfers: HashMap::new(), + next_id: 1, + ip_to_client: HashMap::new(), + } + } + + fn load_json_file Deserialize<'de>>(path: &str) -> Option { + let data = fs::read_to_string(path).ok()?; + serde_json::from_str(&data) + .map_err(|e| eprintln!("[!] Error parsing {}: {}", path, e)) + .ok() + } + + fn save_json_file(path: &str, value: &T) { + match serde_json::to_string_pretty(value) { + Ok(data) => { + if let Err(e) = fs::write(path, data) { + eprintln!("[!] Error saving {}: {}", path, e); + } + } + Err(e) => eprintln!("[!] Error serializing {}: {}", path, e), + } + } + + fn save_banned_ips(&self) { + Self::save_json_file("banip_users.json", &self.banned_ips); + } + + fn save_warn_counts(&self) { + Self::save_json_file("warn_counts.json", &self.warn_counts); + } + + fn is_banned(&self, ip: &str) -> bool { + self.banned_ips.iter().any(|e| e.ip == ip) + } + + fn find_admin(&self, ip: &str, nick: &str) -> Option<&AdminEntry> { + self.admins.iter().find(|a| { + a.ip == ip && a.nick.to_lowercase() == nick.to_lowercase() + }) + } + + fn check_ip_limit(&mut self, ip: &str) -> bool { + let count = self.connections_by_ip.entry(ip.to_string()).or_insert(0); + if *count >= IP_CONNECTION_LIMIT { + return false; + } + *count += 1; + true + } + + fn release_ip(&mut self, ip: &str) { + if let Some(c) = self.connections_by_ip.get_mut(ip) { + if *c > 0 { *c -= 1; } + } + } + + fn alloc_id(&mut self) -> ClientId { + let id = self.next_id; + self.next_id += 1; + id + } + + /// Register a file for download, return transfer_id + fn register_transfer(&mut self, filename: &str, channel: &str, file_path: PathBuf) -> String { + let id = md5_short(&format!("{}{}{}", filename, channel, unix_time())); + self.active_transfers.insert(id.clone(), TransferInfo { + filename: filename.to_string(), + channel: channel.to_string(), + file_path, + created: unix_time(), + }); + id + } + + fn cleanup_old_transfers(&mut self) { + let now = unix_time(); + let before = self.active_transfers.len(); + self.active_transfers.retain(|_, v| now - v.created < TRANSFER_MAX_AGE_SECS as f64); + let removed = before - self.active_transfers.len(); + if removed > 0 { + println!("[FileProtocol] Cleaned up {} old transfers", removed); + } + } +} + +type SharedState = Arc>; +type SharedPluginManager = Arc>; + +// ─── Helper functions ───────────────────────────────────────────────────────── + +fn unix_time() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +fn md5_short(input: &str) -> String { + let digest = md5::compute(input.as_bytes()); + format!("{:x}", digest)[..8].to_string() +} + +static NICK_REGEX: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_]{3,16}$").unwrap()); + +fn is_valid_name(name: &str) -> bool { + NICK_REGEX.is_match(name) +} + +fn parse_colors_ansi(text: &str) -> String { + let color_map: HashMap = [ + ('0', "\x1b[30m"), ('1', "\x1b[34m"), ('2', "\x1b[32m"), ('3', "\x1b[36m"), + ('4', "\x1b[31m"), ('5', "\x1b[35m"), ('6', "\x1b[33m"), ('7', "\x1b[37m"), + ('8', "\x1b[90m"), ('9', "\x1b[94m"), ('a', "\x1b[92m"), ('b', "\x1b[96m"), + ('c', "\x1b[91m"), ('d', "\x1b[95m"), ('e', "\x1b[93m"), ('f', "\x1b[97m"), + ].iter().cloned().collect(); + + let style_map: HashMap = [ + ('l', "\x1b[1m"), ('o', "\x1b[3m"), ('n', "\x1b[4m"), ('m', "\x1b[9m"), + ].iter().cloned().collect(); + + let mut result = String::new(); + let chars: Vec = text.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '&' && i + 1 < chars.len() { + let code = chars[i + 1].to_ascii_lowercase(); + if code == 'r' { + result.push_str("\x1b[0m"); + i += 2; + continue; + } else if let Some(ansi) = color_map.get(&code) { + result.push_str(ansi); + i += 2; + continue; + } else if let Some(ansi) = style_map.get(&code) { + result.push_str(ansi); + i += 2; + continue; + } + } + result.push(chars[i]); + i += 1; + } + result.push_str("\x1b[0m"); + result +} + +fn format_message(nickname: &str, channel: &str, prefix: &str, msg: &str) -> String { + let ts = Local::now().format("[%H:%M]"); + let ch_colored = format!("&2#{}&r", channel); + let nick_colored = format!("&9{}&r", nickname); + if !prefix.is_empty() { + format!("{} [{}] {} {}: {}", ts, ch_colored, prefix, nick_colored, msg) + } else { + format!("{} [{}] {}: {}", ts, ch_colored, nick_colored, msg) + } +} + +fn format_file_message(nickname: &str, channel: &str, file_url: &str, size_bytes: u64) -> String { + let ts = Local::now().format("[%H:%M]"); + let size_str = if size_bytes >= 1024 * 1024 { + let mb = (size_bytes as f64) / (1024.0 * 1024.0); + format!(" ({:.2} MB)", mb) + } else if size_bytes >= 1024 { + let kb = (size_bytes as f64) / 1024.0; + format!(" ({:.1} KB)", kb) + } else { + format!(" ({} B)", size_bytes) + }; + format!("{} [&2#{}&r] &9{}&r:{} - {}", ts, channel, nickname, file_url, size_str) +} + +fn generate_file_url(filename: &str, channel: &str, transfer_id: &str) -> String { + format!("pnft://file/{}/{}?channel={}", transfer_id, filename, channel) +} + +fn parse_file_url(url: &str) -> Option<(String, String, String)> { + // returns (transfer_id, filename, channel) + if !url.starts_with("pnft://file/") { + return None; + } + let rest = &url["pnft://file/".len()..]; + let (transfer_id, remainder) = rest.split_once('/')?; + let (filename, channel) = if let Some((fname, params)) = remainder.split_once('?') { + let channel = params.split('&') + .find_map(|p| p.strip_prefix("channel=")) + .unwrap_or("main") + .to_string(); + (fname.to_string(), channel) + } else { + (remainder.to_string(), "main".to_string()) + }; + Some((transfer_id.to_string(), filename, channel)) +} + +// ─── Multiplexed frame I/O ──────────────────────────────────────────────────── +// Wire format: [1 byte stream][4 bytes length BE][N bytes payload] + +async fn send_frame( + writer: &mut W, + stream: u8, + data: &[u8], +) -> tokio::io::Result<()> { + writer.write_all(&[stream]).await?; + writer.write_all(&(data.len() as u32).to_be_bytes()).await?; + writer.write_all(data).await?; + writer.flush().await?; + Ok(()) +} + +async fn recv_frame(reader: &mut R) -> Option<(StreamKind, Vec)> { + // 1 байт тип + 4 байта длина + let mut header = [0u8; 5]; + reader.read_exact(&mut header).await.ok()?; + + let kind = StreamKind::from(header[0]); + let length = u32::from_be_bytes(header[1..5].try_into().unwrap()) as usize; + if length == 0 || length > 64 * 1024 * 1024 { + return None; + } + let mut payload = vec![0u8; length]; + reader.read_exact(&mut payload).await.ok()?; + Some((kind, payload)) +} + +async fn recv_frame_timeout( + reader: &mut R, + timeout: u64, +) -> Option<(StreamKind, Vec)> { + tokio::time::timeout( + std::time::Duration::from_secs(timeout), + recv_frame(reader), + ).await.ok()? +} + + +async fn send_msg(writer: &mut W, message: &str) -> tokio::io::Result<()> { + send_frame(writer, STREAM_CHAT, message.as_bytes()).await +} + +async fn recv_msg(reader: &mut R) -> Option { + loop { + let (kind, payload) = recv_frame(reader).await?; + match kind { + StreamKind::Chat => return String::from_utf8(payload).ok(), + StreamKind::System => { + eprintln!("[MUX] SYS frame received while waiting for CHAT, skipping"); + continue; + } + StreamKind::File => { + eprintln!("[MUX] FILE frame received while waiting for CHAT text, skipping"); + continue; + } + StreamKind::Unknown(t) => { + eprintln!("[MUX] Unknown stream type 0x{:02X}, dropping", t); + continue; + } + } + } +} + +async fn recv_bytes(reader: &mut R, timeout: u64) -> Option> { + let (kind, payload) = recv_frame_timeout(reader, timeout).await?; + match kind { + StreamKind::File => Some(payload), + _ => { + eprintln!("[MUX] Expected FILE frame, got {:?} — treating as file data", kind); + Some(payload) + } + } +} + +async fn send_bytes(writer: &mut W, data: &[u8]) -> tokio::io::Result<()> { + send_frame(writer, STREAM_FILE, data).await +} + +// ─── Channel DB (SQLite) ────────────────────────────────────────────────────── + +fn init_db() { + let conn = Connection::open("channels.db").expect("[!] Cannot open channels.db"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS channels (name TEXT PRIMARY KEY);" + ).expect("[!] Cannot create channels table"); +} + +fn load_channels() -> HashMap> { + let conn = Connection::open("channels.db").expect("[!] Cannot open channels.db"); + let mut stmt = conn.prepare("SELECT name FROM channels").expect("prepare failed"); + let names: Vec = stmt + .query_map([], |row| row.get(0)) + .expect("query failed") + .filter_map(|r| r.ok()) + .collect(); + names.into_iter().map(|n| (n, Vec::new())).collect() +} + +fn db_create_channel(name: &str) -> Result<(), String> { + let conn = Connection::open("channels.db").map_err(|e| e.to_string())?; + conn.execute("INSERT OR IGNORE INTO channels (name) VALUES (?1)", params![name]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn db_delete_channel(name: &str) -> Result<(), String> { + let conn = Connection::open("channels.db").map_err(|e| e.to_string())?; + conn.execute("DELETE FROM channels WHERE name=?1", params![name]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn create_channel(state: &mut ServerState, name: &str) -> String { + if state.channels.contains_key(name) { + return format!("Channel #{} already exists.", name); + } + match db_create_channel(name) { + Ok(_) => { + state.channels.insert(name.to_string(), Vec::new()); + format!("Channel #{} created.", name) + } + Err(e) => format!("Error: {}", e), + } +} + +fn delete_channel(state: &mut ServerState, name: &str) -> String { + if !state.channels.contains_key(name) { + return format!("Channel #{} not found.", name); + } + match db_delete_channel(name) { + Ok(_) => { + state.channels.remove(name); + format!("Channel #{} deleted.", name) + } + Err(e) => format!("Error: {}", e), + } +} + +// ─── Broadcasting ───────────────────────────────────────────────────────────── + +async fn broadcast_to_channel( + state: &ServerState, + channel: &str, + message: &str, + exclude_id: Option, +) { + if let Some(ids) = state.channels.get(channel) { + for &id in ids { + if exclude_id == Some(id) { + continue; + } + if let Some(client) = state.clients.get(&id) { + let client = client.lock().await; + let _ = client.tx.try_send(message.to_string()); + } + } + } +} + +async fn broadcast_system(state: &ServerState, message: &str) { + for client in state.clients.values() { + let client = client.lock().await; + let _ = client.tx.try_send(format!("[System] {}", message)); + } +} + +async fn send_to_client(state: &ServerState, id: ClientId, message: &str) { + if let Some(client) = state.clients.get(&id) { + let client = client.lock().await; + let _ = client.tx.try_send(message.to_string()); + } +} + +// ─── TLS setup ──────────────────────────────────────────────────────────────── + +fn load_tls_config(cfg: &Config) -> Arc { + // Load certificate chain + let cert_chain: Vec> = if Path::new(&cfg.server_pem).exists() { + let pem = fs::File::open(&cfg.server_pem).expect("[!] Cannot open server_pem"); + rustls_pemfile::certs(&mut BufReader::new(pem)) + .filter_map(|c| c.ok()) + .collect() + } else { + let pem = fs::File::open(&cfg.cert_file).expect("[!] Cannot open cert_file"); + rustls_pemfile::certs(&mut BufReader::new(pem)) + .filter_map(|c| c.ok()) + .collect() + }; + + // Load private key + let key_path = if Path::new(&cfg.server_pem).exists() { + &cfg.server_pem + } else { + &cfg.key_file + }; + let key_file = fs::File::open(key_path).expect("[!] Cannot open key file"); + let key = rustls_pemfile::private_key(&mut BufReader::new(key_file)) + .expect("[!] Failed to read private key") + .expect("[!] No private key found"); + + let key = PrivateKeyDer::try_from(key).expect("[!] Invalid key type"); + + if cfg.require_client_cert { + // mTLS — клиент обязан предъявить сертификат + let ca_file = fs::File::open(&cfg.ca_file).expect("[!] Cannot open ca_file"); + let mut root_store = rustls::RootCertStore::empty(); + for cert in rustls_pemfile::certs(&mut BufReader::new(ca_file)).filter_map(|c| c.ok()) { + root_store.add(cert).expect("[!] Failed to add CA cert"); + } + let client_auth = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store)) + .build() + .expect("[!] Failed to build client verifier"); + Arc::new( + ServerConfig::builder() + .with_client_cert_verifier(client_auth) + .with_single_cert(cert_chain, key) + .expect("[!] Invalid TLS config"), + ) + } else { + // Обычный TLS — сертификат клиента не требуется (публичный сервер) + Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, key) + .expect("[!] Invalid TLS config"), + ) + } +} + +// ─── Admin command handler ──────────────────────────────────────────────────── + +async fn handle_admin_command( + state_lock: SharedState, + client_id: ClientId, + command: &str, + args: &str, + _file_dir: &str, +) -> String { + let mut state = state_lock.write().await; + + // Check permissions + let (addr_ip, nickname) = { + if let Some(c) = state.clients.get(&client_id) { + let c = c.lock().await; + let nick = c.nickname.clone().unwrap_or_default(); + let ip = c.addr.ip().to_string(); + (ip, nick) + } else { + return "Client not found.".into(); + } + }; + + if state.find_admin(&addr_ip, &nickname).is_none() { + return "You don't have admin privileges.".into(); + } + + let admin_immunity = state.find_admin(&addr_ip, &nickname) + .map(|a| a.immunity).unwrap_or(0); + + let args_split: Vec<&str> = args.split_whitespace().collect(); + let cmd = command.to_lowercase(); + + match cmd.as_str() { + "/ahelp" => { + "/kick , /banip , /warn , /bans, /unban ".into() + } + + "/kick" => { + if args_split.len() < 2 { + return "Usage: /kick ".into(); + } + let target_nick = args_split[0]; + let reason = args_split[1..].join(" "); + + // Find target by nickname + let target_id = { + let mut found = None; + for (&id, arc) in &state.clients { + if let Ok(c) = arc.try_lock() { + if c.nickname.as_deref().map(|n| n.to_lowercase()) == Some(target_nick.to_lowercase()) { + found = Some(id); + break; + } + } + } + found + }; + + if let Some(tid) = target_id { + // Check immunity + let target_ip = { + if let Some(arc) = state.clients.get(&tid) { + if let Ok(c) = arc.try_lock() { + c.addr.ip().to_string() + } else { String::new() } + } else { String::new() } + }; + let target_immunity = state.find_admin(&target_ip, target_nick) + .map(|a| a.immunity).unwrap_or(0); + + if target_immunity >= admin_immunity { + return "Cannot kick an admin with equal or higher immunity.".into(); + } + + send_to_client(&state, tid, &format!("You have been kicked. Reason: {}", reason)).await; + // Deactivate: extract channel first, then mutate state + let client_channel = state.clients.get(&tid) + .and_then(|arc| arc.try_lock().ok()) + .and_then(|mut c| { c.active = false; c.channel.take() }); + if let Some(ch) = client_channel { + if let Some(members) = state.channels.get_mut(&ch) { + members.retain(|&x| x != tid); + } + } + state.ip_to_client.retain(|_, &mut id| id != tid); + state.clients.remove(&tid); + state.release_ip(&target_ip); + + let sys_msg = format!( + "Admin {} kicked user {} for reason: {}", + nickname, target_nick, reason + ); + broadcast_system(&state, &sys_msg).await; + format!("User {} has been kicked.", target_nick) + } else { + "User not found.".into() + } + } + + "/banip" => { + if args_split.len() < 2 { + return "Usage: /banip ".into(); + } + let target_nick = args_split[0]; + let reason = args_split[1..].join(" "); + + let (target_id, target_ip) = { + let mut found = None; + for (&id, arc) in &state.clients { + if let Ok(c) = arc.try_lock() { + if c.nickname.as_deref().map(|n| n.to_lowercase()) == Some(target_nick.to_lowercase()) { + found = Some((id, c.addr.ip().to_string())); + break; + } + } + } + match found { + Some(x) => x, + None => return "User not found.".into(), + } + }; + + let target_immunity = state.find_admin(&target_ip, target_nick) + .map(|a| a.immunity).unwrap_or(0); + if target_immunity >= admin_immunity { + return "Cannot ban an admin with equal or higher immunity.".into(); + } + + state.banned_ips.push(BanEntry { + ip: target_ip.clone(), + nick: target_nick.to_string(), + reason: reason.clone(), + time: unix_time(), + }); + state.save_banned_ips(); + + send_to_client(&state, target_id, &format!("You have been IP banned. Reason: {}", reason)).await; + + let client_channel = state.clients.get(&target_id) + .and_then(|arc| arc.try_lock().ok()) + .and_then(|mut c| { c.active = false; c.channel.take() }); + if let Some(ch) = client_channel { + if let Some(members) = state.channels.get_mut(&ch) { + members.retain(|&x| x != target_id); + } + } + state.ip_to_client.retain(|_, &mut id| id != target_id); + state.clients.remove(&target_id); + state.release_ip(&target_ip); + + let sys_msg = format!( + "Admin {} blocked IP {} of user {} for reason: {}", + nickname, target_ip, target_nick, reason + ); + broadcast_system(&state, &sys_msg).await; + format!("User {} has been IP banned on {}.", target_nick, target_ip) + } + + "/warn" => { + if args_split.len() != 1 { + return "Usage: /warn ".into(); + } + let target_nick = args_split[0]; + + let (target_id, target_ip) = { + let mut found = None; + for (&id, arc) in &state.clients { + if let Ok(c) = arc.try_lock() { + if c.nickname.as_deref().map(|n| n.to_lowercase()) == Some(target_nick.to_lowercase()) { + found = Some((id, c.addr.ip().to_string())); + break; + } + } + } + match found { + Some(x) => x, + None => return "User not found.".into(), + } + }; + + let target_immunity = state.find_admin(&target_ip, target_nick) + .map(|a| a.immunity).unwrap_or(0); + if target_immunity >= admin_immunity { + return "Cannot warn an admin with equal or higher immunity.".into(); + } + + let count = state.warn_counts.entry(target_ip.clone()).or_insert(0); + *count += 1; + let current = *count; + state.save_warn_counts(); + + send_to_client(&state, target_id, &format!("Warning! ({}/{})", current, WARN_LIMIT)).await; + + let sys_msg = format!( + "Admin {} warned user {} ({}/{})", + nickname, target_nick, current, WARN_LIMIT + ); + broadcast_system(&state, &sys_msg).await; + + if current >= WARN_LIMIT { + state.banned_ips.push(BanEntry { + ip: target_ip.clone(), + nick: target_nick.to_string(), + reason: "Multiple warnings".into(), + time: unix_time(), + }); + state.save_banned_ips(); + state.warn_counts.remove(&target_ip); + state.save_warn_counts(); + + send_to_client(&state, target_id, "You have been banned for multiple warnings.").await; + + let client_channel = state.clients.get(&target_id) + .and_then(|arc| arc.try_lock().ok()) + .and_then(|mut c| { c.active = false; c.channel.take() }); + if let Some(ch) = client_channel { + if let Some(members) = state.channels.get_mut(&ch) { + members.retain(|&x| x != target_id); + } + } + state.ip_to_client.retain(|_, &mut id| id != target_id); + state.clients.remove(&target_id); + state.release_ip(&target_ip); + + let sys_msg2 = format!( + "Admin {} banned IP of user {} ({}) for exceeding warning limit.", + nickname, target_nick, target_ip + ); + broadcast_system(&state, &sys_msg2).await; + return format!("User {} has been banned for warnings.", target_nick); + } + + format!("User {} has been warned ({}/{}).", target_nick, current, WARN_LIMIT) + } + + "/bans" => { + if state.banned_ips.is_empty() { + "Ban list is empty.".into() + } else { + let lines: Vec = state.banned_ips.iter().enumerate().map(|(i, ban)| { + let ts = chrono::DateTime::from_timestamp(ban.time as i64, 0) + .unwrap_or_default() + .format("%Y-%m-%d %H:%M:%S"); + format!("{}. {} | {} | {} | {}", i + 1, ban.nick, ban.ip, ban.reason, ts) + }).collect(); + lines.join("\n") + } + } + + "/unban" => { + if args_split.len() != 1 { + return "Usage: /unban ".into(); + } + let target_nick = args_split[0].to_lowercase(); + let before = state.banned_ips.len(); + let mut unbanned_ip = String::new(); + state.banned_ips.retain(|e| { + if e.nick.to_lowercase() == target_nick { + unbanned_ip = e.ip.clone(); + false + } else { + true + } + }); + if state.banned_ips.len() < before { + state.save_banned_ips(); + format!("IP {} has been unbanned.", unbanned_ip) + } else { + "User not found or not banned.".into() + } + } + + _ => "Unknown admin command. Use /ahelp for command list.".into(), + } +} + +// ─── File server ────────────────────────────────────────────────────────────── + +async fn run_file_server( + host: String, + port: u16, + tls_acceptor: TlsAcceptor, + state_lock: SharedState, + file_dir: String, + plugin_manager: SharedPluginManager, +) { + let listener = TcpListener::bind(format!("{}:{}", host, port)) + .await + .expect("[FileServer] Cannot bind"); + println!("[FileServer] Started on {}:{} (TLS with client verification: ENABLED)", host, port); + + loop { + match listener.accept().await { + Ok((stream, addr)) => { + let acceptor = tls_acceptor.clone(); + let state = state_lock.clone(); + let dir = file_dir.clone(); + let pm = plugin_manager.clone(); + let client_ip = addr.ip().to_string(); + tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + let (mut reader, mut writer) = tokio::io::split(tls_stream); + println!("[FileServer] Accepted connection from {}", addr); + if let Err(e) = handle_file_client(&mut reader, &mut writer, &state, &dir, &pm, &client_ip).await { + eprintln!("[FileServer] Client error {}: {}", addr, e); + } + } + Err(e) => { + eprintln!("[FileServer] TLS error from {}: {}", addr, e); + } + } + }); + } + Err(e) => eprintln!("[FileServer] Accept error: {}", e), + } + } +} + +async fn handle_file_client( + reader: &mut R, + writer: &mut W, + state_lock: &SharedState, + file_dir: &str, + plugin_manager: &SharedPluginManager, + client_ip: &str, +) -> Result<(), Box> +where + R: AsyncReadExt + Unpin, + W: AsyncWriteExt + Unpin, +{ + let request_bytes = recv_bytes(reader, 10).await + .ok_or("No request received")?; + let request: Value = serde_json::from_slice(&request_bytes)?; + + match request.get("action").and_then(|v| v.as_str()) { + Some("download") => { + let transfer_id = request["transfer_id"].as_str().unwrap_or(""); + handle_file_download(reader, writer, state_lock, transfer_id).await?; + } + Some("upload") => { + let filename = request["filename"].as_str().unwrap_or("").to_string(); + let channel = request["channel"].as_str().unwrap_or("main").to_string(); + let file_size = request["size"].as_u64().unwrap_or(0); + handle_file_upload_server(reader, writer, state_lock, file_dir, &filename, &channel, file_size, client_ip, plugin_manager).await?; + } + _ => { + let err = json!({"error": "Unknown action"}); + send_bytes(writer, err.to_string().as_bytes()).await?; + } + } + Ok(()) +} + +async fn handle_file_download( + reader: &mut R, + writer: &mut W, + state_lock: &SharedState, + transfer_id: &str, +) -> Result<(), Box> +where + R: AsyncReadExt + Unpin, + W: AsyncWriteExt + Unpin, +{ + let (file_path, filename, file_size) = { + let state = state_lock.read().await; + match state.active_transfers.get(transfer_id) { + None => { + send_bytes(writer, json!({"error": "Transfer not found"}).to_string().as_bytes()).await?; + return Ok(()); + } + Some(info) => { + let path = info.file_path.clone(); + if !path.exists() { + send_bytes(writer, json!({"error": "File not found"}).to_string().as_bytes()).await?; + return Ok(()); + } + let size = fs::metadata(&path)?.len(); + (path, info.filename.clone(), size) + } + } + }; + + let response = json!({"status": "ok", "filename": filename, "size": file_size}); + send_bytes(writer, response.to_string().as_bytes()).await?; + + let ready = recv_bytes(reader, 10).await.ok_or("No READY signal")?; + if ready.as_slice() != b"READY" { + return Ok(()); + } + + let mut f = tokio::fs::File::open(&file_path).await?; + let mut sent = 0u64; + let mut buf = vec![0u8; FILE_CHUNK_SIZE]; + while sent < file_size { + let n = f.read(&mut buf).await?; + if n == 0 { break; } + send_bytes(writer, &buf[..n]).await?; + sent += n as u64; + println!("[FileServer] Sent {}/{} bytes", sent, file_size); + } + + println!("[FileServer] Download completed: {}", filename); + Ok(()) +} + +async fn handle_file_upload_server( + reader: &mut R, + writer: &mut W, + state_lock: &SharedState, + file_dir: &str, + filename: &str, + channel: &str, + file_size: u64, + client_ip: &str, + plugin_manager: &SharedPluginManager, +) -> Result<(), Box> +where + R: AsyncReadExt + Unpin, + W: AsyncWriteExt + Unpin, +{ + let uploader_nick = { + let state = state_lock.read().await; + state.ip_to_client.get(client_ip) + .and_then(|cid| state.clients.get(cid)) + .and_then(|arc| arc.try_lock().ok()) + .and_then(|c| c.nickname.clone()) + .unwrap_or_else(|| "unknown".to_string()) + }; + + if filename.is_empty() || file_size == 0 { + send_bytes(writer, json!({"error": "Invalid upload parameters"}).to_string().as_bytes()).await?; + return Ok(()); + } + + let safe_filename = Path::new(filename).file_name() + .and_then(|n| n.to_str()).unwrap_or("file").to_string(); + let dest_dir = Path::new(file_dir).join(channel); + fs::create_dir_all(&dest_dir)?; + let dest_path = dest_dir.join(&safe_filename); + + send_bytes(writer, json!({"status": "ready"}).to_string().as_bytes()).await?; + + let mut file = tokio::fs::File::create(&dest_path).await?; + let mut received = 0u64; + + while received < file_size { + let chunk = recv_bytes(reader, 30).await.ok_or("Timeout receiving chunk")?; + file.write_all(&chunk).await?; + received += chunk.len() as u64; + println!("[FileServer] Received {}/{} bytes", received, file_size); + } + + file.flush().await?; + + if received == file_size { + let transfer_id = { + let mut state = state_lock.write().await; + state.register_transfer(&safe_filename, channel, dest_path.clone()) + }; + let download_url = generate_file_url(&safe_filename, channel, &transfer_id); + + { + let state = state_lock.read().await; + let msg = format_file_message(&uploader_nick, channel, &download_url, received); + broadcast_to_channel(&state, channel, &msg, None).await; + + let uploader_id = state.clients.iter().find_map(|(&id, arc)| { + arc.try_lock().ok().and_then(|c| { + if c.nickname.as_deref() == Some(&uploader_nick) { + Some(id) + } else { + None + } + }) + }); + + if let Some(_uid) = uploader_id { + let _size_str = if received >= 1024 * 1024 { + format!("{:.2} MB", received as f64 / (1024.0 * 1024.0)) + } else if received >= 1024 { + format!("{:.1} KB", received as f64 / 1024.0) + } else { + format!("{} B", received) + }; + } + } + + { + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::FileUploaded { + client_id: 0, + filename: safe_filename.clone(), + size: received, + url: download_url.clone(), + }); + } + + let resp = json!({ + "status": "success", + "filename": safe_filename, + "size": received, + "transfer_id": transfer_id, + "download_url": download_url + }); + send_bytes(writer, resp.to_string().as_bytes()).await?; + println!("[FileServer] Upload completed: {}", safe_filename); + } else { + let _ = tokio::fs::remove_file(&dest_path).await; + send_bytes(writer, + json!({"error": format!("Incomplete upload: {}/{}", received, file_size)}).to_string().as_bytes() + ).await?; + } + Ok(()) +} + +// ─── Chat server client handler ─────────────────────────────────────────────── + +async fn handle_client( + stream: TcpStream, + addr: SocketAddr, + acceptor: TlsAcceptor, + state_lock: SharedState, + config: Arc, + plugin_manager: SharedPluginManager, +) { + // TLS handshake + let tls_stream = match acceptor.accept(stream).await { + Ok(s) => s, + Err(e) => { + eprintln!("[!] TLS error from {}: {}", addr, e); + return; + } + }; + + let ip = addr.ip().to_string(); + + // Ban / connection limit checks + { + let mut state = state_lock.write().await; + if state.is_banned(&ip) { + eprintln!("[-] Banned IP {} tried to connect", ip); + return; + } + if !state.check_ip_limit(&ip) { + drop(state); + // No convenient way to send a message without registering the client, + // just close the connection + return; + } + } + + // Create an mpsc channel for sending messages to this client + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); + + let client_id = { + let mut state = state_lock.write().await; + let id = state.alloc_id(); + let cs = Arc::new(Mutex::new(ClientState::new(id, addr, tx))); + state.clients.insert(id, cs); + id + }; + + println!("[+] Connection from {}", addr); + + // Event: client connected + { + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::ClientConnected { client_id }); + } + + let (reader_half, writer_half) = tokio::io::split(tls_stream); + let reader_half = Arc::new(tokio::sync::Mutex::new(reader_half)); + let writer_half = Arc::new(tokio::sync::Mutex::new(writer_half)); + + // Writer loop task + let wh = writer_half.clone(); + let write_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + let mut w = wh.lock().await; + if send_msg(&mut *w, &msg).await.is_err() { + break; + } + } + }); + + // Send welcome message + { + let state = state_lock.read().await; + if let Some(arc) = state.clients.get(&client_id) { + let c = arc.lock().await; + let _ = c.tx.try_send(parse_colors_ansi(&config.welcome_text)); + } + } + + // Main read loop + let result = client_loop( + reader_half.clone(), + writer_half.clone(), + client_id, + addr, + state_lock.clone(), + config.clone(), + plugin_manager.clone(), + ).await; + + if let Err(e) = result { + eprintln!("[!] Client error {}: {}", addr, e); + } + + // Event: client disconnected + { + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::ClientDisconnected { client_id }); + } + + // Cleanup + write_task.abort(); + { + let mut state = state_lock.write().await; + state.ip_to_client.remove(&ip); + + // Extract channel before mutating state + let client_channel = state.clients.get(&client_id) + .and_then(|arc| arc.try_lock().ok()) + .and_then(|c| c.channel.clone()); + if let Some(ch) = client_channel { + if let Some(members) = state.channels.get_mut(&ch) { + members.retain(|&x| x != client_id); + } + } + state.clients.remove(&client_id); + state.release_ip(&ip); + } + println!("[-] Disconnection from {}", addr); +} + +async fn client_loop( + reader: Arc>, + writer_arc: Arc>, + client_id: ClientId, + _addr: SocketAddr, + state_lock: SharedState, + config: Arc, + plugin_manager: SharedPluginManager, +) -> Result<(), Box> +where + R: AsyncReadExt + Unpin + 'static, + W: AsyncWriteExt + Unpin + 'static, +{ + let mut ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); + ping_interval.tick().await; + + loop { + let active = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .map(|a| a.try_lock().map(|c| c.active).unwrap_or(true)) + .unwrap_or(false) + }; + if !active { break; } + + let frame_res = tokio::select! { + f = async { + let mut r = reader.lock().await; + recv_frame(&mut *r).await + } => f, + _ = ping_interval.tick() => { + let mut w = writer_arc.lock().await; + if send_frame(&mut *w, STREAM_SYS, b"PING").await.is_err() { + break; + } + continue; + } + }; + + let (kind, payload) = match frame_res { + Some(f) => { + { + let state = state_lock.read().await; + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(mut c) = arc.try_lock() { + c.last_activity = std::time::Instant::now(); + } + } + } + f + } + None => break, + }; + + match kind { + StreamKind::System => { + if payload == b"PING" { + let mut w = writer_arc.lock().await; + let _ = send_frame(&mut *w, STREAM_SYS, b"PONG").await; + } + continue; + } + StreamKind::File => { + eprintln!("[MUX] Unexpected FILE frame in chat loop for client {}", client_id); + continue; + } + StreamKind::Unknown(t) => { + eprintln!("[MUX] Unknown stream type 0x{:02X} from client {}, skipping", t, client_id); + continue; + } + StreamKind::Chat => {} + } + + let msg = match String::from_utf8(payload) { + Ok(s) => s, + Err(_) => break, + }; + + let msg = msg.trim().to_string(); + if msg.is_empty() { continue; } + + if msg.starts_with('/') { + let (command, args) = if let Some(space) = msg.find(' ') { + (msg[..space].to_string(), msg[space + 1..].to_string()) + } else { + (msg.clone(), String::new()) + }; + + // Check plugin commands first + let plugin_response = { + let pm = plugin_manager.lock().await; + if pm.has_command(&command) { + Some(pm.dispatch_command(&command, client_id, &args)) + } else { + None + } + }; + + if let Some(resp) = plugin_response { + if let Some(r) = resp { + let state = state_lock.read().await; + send_to_client(&state, client_id, &r).await; + } + // Check activity and continue + let still_active = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .map(|a| a.try_lock().map(|c| c.active).unwrap_or(true)) + .unwrap_or(false) + }; + if !still_active { break; } + continue; + } + + let response = dispatch_command( + &command, + &args, + client_id, + state_lock.clone(), + config.clone(), + plugin_manager.clone(), + ).await; + + if let Some(resp) = response { + let state = state_lock.read().await; + send_to_client(&state, client_id, &resp).await; + } + + // Check if client became inactive (kicked, etc.) + let still_active = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .map(|a| a.try_lock().map(|c| c.active).unwrap_or(true)) + .unwrap_or(false) + }; + if !still_active { break; } + } else { + // Regular channel message + let (nickname, channel, prefix) = { + let state = state_lock.read().await; + if let Some(arc) = state.clients.get(&client_id) { + let c = arc.lock().await; + ( + c.nickname.clone(), + c.channel.clone(), + c.prefix.clone(), + ) + } else { + break; + } + }; + + if nickname.is_none() { + let state = state_lock.read().await; + send_to_client(&state, client_id, "First set your nick with /nick ").await; + continue; + } + let channel = match channel { + Some(ch) => ch, + None => { + let state = state_lock.read().await; + send_to_client(&state, client_id, "You're not in a channel. Use /join ").await; + continue; + } + }; + + let nick = nickname.unwrap(); + let formatted = format_message(&nick, &channel, &prefix, &msg); + println!("{}", parse_colors_ansi(&formatted)); + + { + let state = state_lock.read().await; + broadcast_to_channel(&state, &channel, &formatted, None).await; + } + + // Event: message sent + { + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::MessageSent { + client_id, + msg: msg.clone(), + formatted: formatted.clone(), + }); + } + } + } + Ok(()) +} + +/// Client command dispatcher. Returns Option — response to send to the client. +/// File transfers are fully handled by the dedicated file server on a separate port. +async fn dispatch_command( + command: &str, + args: &str, + client_id: ClientId, + state_lock: SharedState, + config: Arc, + plugin_manager: SharedPluginManager, +) -> Option { + match command.to_lowercase().as_str() { + "/nick" => { + let new_nick = args.trim().to_string(); + if new_nick.is_empty() { + return Some("Usage: /nick ".into()); + } + if !is_valid_name(&new_nick) { + return Some("Nick must contain only latin letters and numbers, 3-16 characters.".into()); + } + + { + let state = state_lock.read().await; + for (&id, arc) in &state.clients { + if id == client_id { continue; } + if let Ok(c) = arc.try_lock() { + if c.nickname.as_deref().map(|n| n.to_lowercase()) == Some(new_nick.to_lowercase()) { + return Some("Nick is already in use.".into()); + } + } + } + + for (ip, &cid) in &state.ip_to_client { + if cid == client_id { continue; } + if let Some(arc) = state.clients.get(&cid) { + if let Ok(c) = arc.try_lock() { + if c.nickname.as_deref() == Some(&new_nick) { + return Some("Nick is already in use.".into()); + } + } + } + } + } + + // Apply + let ip = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .map(|c| c.addr.ip().to_string()) + .unwrap_or_default() + }; + + let old_nick = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.nickname.clone()) + .unwrap_or_default() + }; + + { + let mut state = state_lock.write().await; + let admin_prefix = state.find_admin(&ip, &new_nick) + .map(|a| a.prefix.clone()) + .unwrap_or_default(); + + let ip_to_register = { + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(mut c) = arc.try_lock() { + c.nickname = Some(new_nick.clone()); + c.prefix = admin_prefix; + Some(c.addr.ip().to_string()) + } else { + None + } + } else { + None + } + }; + + if let Some(ip) = ip_to_register { + state.ip_to_client.insert(ip, client_id); + } + } + + // Event: nickname changed + { + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::NicknameChanged { + client_id, + old_nick, + new_nick: new_nick.clone(), + }); + } + + Some(format!("Nick set: {}", new_nick)) + } + + "/prefix" => { + let new_prefix = args.trim().to_string(); + if new_prefix.len() > 16 { + return Some("Prefix must be no longer than 16 characters.".into()); + } + let state = state_lock.read().await; + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(mut c) = arc.try_lock() { + c.prefix = new_prefix.clone(); + } + } + Some(format!("Prefix set: {}", new_prefix)) + } + + "/join" => { + let channel_name = args.trim().to_string(); + let mut state = state_lock.write().await; + + // Already in a channel? + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(c) = arc.try_lock() { + if let Some(ch) = &c.channel { + return Some(format!("You're already in channel #{}", ch)); + } + } + } + + if !state.channels.contains_key(&channel_name) { + return Some(format!("Channel #{} doesn't exist.", channel_name)); + } + + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(mut c) = arc.try_lock() { + c.channel = Some(channel_name.clone()); + } + } + if let Some(members) = state.channels.get_mut(&channel_name) { + members.push(client_id); + } + drop(state); + + // Event: joined channel + { + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::ChannelJoined { + client_id, + channel: channel_name.clone(), + }); + } + + Some(format!("You joined channel #{}", channel_name)) + } + + "/leave" => { + let ch = { + let mut state = state_lock.write().await; + let ch = { + if let Some(arc) = state.clients.get(&client_id) { + if let Ok(mut c) = arc.try_lock() { + c.channel.take() + } else { None } + } else { None } + }; + if let Some(ch) = &ch { + if let Some(members) = state.channels.get_mut(ch) { + members.retain(|&x| x != client_id); + } + } + ch + }; + + if let Some(ref ch) = ch { + // Event: left channel + let pm = plugin_manager.lock().await; + pm.trigger_event(&EventArgs::ChannelLeft { + client_id, + channel: ch.clone(), + }); + } + + match ch { + Some(ch) => Some(format!("You left channel &2#{}&r", ch)), + None => Some("You're not in a channel.".into()), + } + } + + "/who" => { + let state = state_lock.read().await; + let channel = { + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.channel.clone()) + }; + match channel { + None => Some("You're not in a channel.".into()), + Some(ch) => { + if let Some(members) = state.channels.get(&ch) { + let names: Vec = members.iter().filter_map(|&id| { + state.clients.get(&id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.nickname.clone()) + }).collect(); + Some(format!("Channel &2#{}&r members: {}", ch, names.join(", "))) + } else { + Some("You're not in a channel.".into()) + } + } + } + } + + "/list" => { + let state = state_lock.read().await; + let channels: Vec = state.channels.keys() + .map(|k| format!("&2#{}&r", k)) + .collect(); + Some(format!("Channel list:\n{}", channels.join("\n"))) + } + + "/files" => { + let channel = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.channel.clone()) + }; + let ch = match channel { + None => return Some("You're not in a channel.".into()), + Some(c) => c, + }; + let dir = Path::new(&config.file_dir).join(&ch); + if !dir.exists() { + return Some(format!("No files in channel &2#{}&r", ch)); + } + let files: Vec = fs::read_dir(&dir) + .map(|rd| rd.filter_map(|e| e.ok()) + .map(|e| format!("- {}", e.file_name().to_string_lossy())) + .collect()) + .unwrap_or_default(); + if files.is_empty() { + Some(format!("No files in channel &2#{}&r", ch)) + } else { + Some(format!("Files in &2#{}&r:\n{}", ch, files.join("\n"))) + } + } + + "/msg" => { + let parts: Vec<&str> = args.splitn(2, ' ').collect(); + if parts.len() < 2 { + return Some("Format: /msg ".into()); + } + let to = parts[0]; + let message = parts[1]; + + let (sender_nick, target_id) = { + let state = state_lock.read().await; + let snick = state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.nickname.clone()) + .unwrap_or_else(|| "???".into()); + let tid = state.clients.iter().find_map(|(&id, arc)| { + arc.try_lock().ok().and_then(|c| { + if c.nickname.as_deref().map(|n| n.to_lowercase()) == Some(to.to_lowercase()) { + Some(id) + } else { + None + } + }) + }); + (snick, tid) + }; + + let ts = Local::now().format("[%H:%M]"); + match target_id { + None => Some(format!("User '{}' not found.", to)), + Some(tid) => { + let state = state_lock.read().await; + let from_msg = format!("{} [You → {}]: {}", ts, to, message); + let to_msg = format!("{} [{} → You]: {}", ts, sender_nick, message); + send_to_client(&state, tid, &to_msg).await; + Some(from_msg) + } + } + } + + "/help" => { + let ip = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .map(|c| c.addr.ip().to_string()) + .unwrap_or_default() + }; + let nick = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.nickname.clone()) + .unwrap_or_default() + }; + let is_admin = { + let state = state_lock.read().await; + state.find_admin(&ip, &nick).is_some() + }; + + let mut help = String::from( + "=== Basic Commands ===\n\ + /nick - set your nickname\n\ + /prefix - set display prefix\n\ + /join - join a channel\n\ + /leave - leave current channel\n\ + /who - list users in current channel\n\ + /list - list all channels\n\ + /msg - send private message\n\ + /files - list files in channel\n\ + /sendfile - upload file\n\ + /getfile - download file by URL\n\ + /version - server version\n\ + /admins - list admins in your channel" + ); + + if is_admin { + help.push_str( + "\n\n=== Admin Commands ===\n\ + /ahelp - detailed admin help\n\ + /kick - kick user\n\ + /banip - ban user IP\n\ + /warn - warn user\n\ + /bans - list banned IPs\n\ + /unban - unban user" + ); + } + + // Add plugin commands + { + let pm = plugin_manager.lock().await; + let plugin_cmds = pm.get_all_plugin_commands(); + if !plugin_cmds.is_empty() { + help.push_str("\n\n=== Plugin Commands ==="); + let mut sorted = plugin_cmds; + sorted.sort_by(|a, b| a.0.cmp(&b.0)); + for (cmd, plugin) in sorted { + help.push_str(&format!("\n{} ({})", cmd, plugin)); + } + } + } + + Some(help) + } + + "/version" => { + Some(format!("Server version: {} (TLS enabled)", SERVER_VERSION)) + } + + "/admins" => { + let (channel, admins_in_ch) = { + let state = state_lock.read().await; + let ch = state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.channel.clone()); + if let Some(ref ch) = ch { + let nicks: Vec = state.channels.get(ch) + .map(|ids| ids.iter().filter_map(|&id| { + state.clients.get(&id)?.try_lock().ok().and_then(|c| { + let ip = c.addr.ip().to_string(); + let nick = c.nickname.clone()?; + if state.find_admin(&ip, &nick).is_some() { + Some(nick) + } else { + None + } + }) + }).collect()) + .unwrap_or_default(); + (ch.clone(), nicks) + } else { + return Some("You're not in a channel.".into()); + } + }; + + if admins_in_ch.is_empty() { + Some("No admins in this channel.".into()) + } else { + let list = admins_in_ch.iter().enumerate() + .map(|(i, n)| format!("{}. {}", i + 1, n)) + .collect::>().join("\n"); + Some(format!("Admins in channel &2#{}&r:\n{}", channel, list)) + } + } + + "/sendfile" => { + // Check: nickname + channel + let (nick_ok, channel_ok) = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .map(|c| (c.nickname.is_some(), c.channel.is_some())) + .unwrap_or((false, false)) + }; + if !nick_ok || !channel_ok { + return Some("First set nickname and join channel".into()); + } + + let parts: Vec<&str> = args.trim().splitn(2, ' ').collect(); + if parts.len() < 2 { + return Some("Usage: /sendfile ".into()); + } + let filename = parts[0].to_string(); + let file_size: u64 = match parts[1].parse() { + Ok(s) => s, + Err(_) => return Some("Invalid file size".into()), + }; + + let max_bytes = config.max_file_size * 1024 * 1024; + if file_size > max_bytes { + return Some(format!("FILE_ERROR: File too large (max {} MB)", config.max_file_size)); + } + + // Get channel and nickname from server state (no client input needed) + let channel = { + let state = state_lock.read().await; + let client = state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .unwrap(); + client.channel.clone().unwrap() + }; + + // Redirect client to the dedicated file server port for the actual upload. + // The client must open a new TLS connection to file_server_port and send: + // { "action": "upload", "filename": , "channel": , "size": , "nick": } + // The nick field is set by the server here — the client just echoes it back in the JSON. + let safe_name = Path::new(&filename).file_name() + .and_then(|n| n.to_str()).unwrap_or("file").to_string(); + + Some(format!( + "FILE_REDIRECT_UPLOAD:{}:{}:{}:{}", + config.file_server_port, safe_name, channel, file_size + )) + } + + "/getfile" => { + let nick_ok = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .map(|c| c.nickname.is_some() && c.channel.is_some()) + .unwrap_or(false) + }; + if !nick_ok { + return Some("First set nickname and join channel".into()); + } + + let file_url = args.trim().to_string(); + if file_url.is_empty() { + return Some("Usage: /getfile ".into()); + } + if !file_url.starts_with("pnft://file/") { + return Some("Invalid file URL format. Must start with pnft://file/".into()); + } + + let parsed = match parse_file_url(&file_url) { + Some(p) => p, + None => return Some("FILE_ERROR: Invalid file URL".into()), + }; + let (transfer_id, _filename, url_channel) = parsed; + + // Check channel access + let client_channel = { + let state = state_lock.read().await; + state.clients.get(&client_id) + .and_then(|a| a.try_lock().ok()) + .and_then(|c| c.channel.clone()) + }; + if client_channel.as_deref() != Some(&url_channel) { + return Some("FILE_ERROR: File not available in your channel".into()); + } + + // Verify the transfer still exists before redirecting + let transfer_exists = { + let state = state_lock.read().await; + state.active_transfers.get(&transfer_id) + .map(|info| info.file_path.exists()) + .unwrap_or(false) + }; + if !transfer_exists { + return Some("FILE_ERROR: File transfer not found or expired".into()); + } + + // Redirect client to the dedicated file server port for the actual download. + // The client must open a new TLS connection to file_server_port and send: + // { "action": "download", "transfer_id": } + // After which the file server handles the binary transfer independently. + Some(format!( + "FILE_REDIRECT_DOWNLOAD:{}:{}", + config.file_server_port, transfer_id + )) + } + + "/ahelp" | "/kick" | "/banip" | "/warn" | "/bans" | "/unban" => { + let resp = handle_admin_command( + state_lock.clone(), + client_id, + command, + args, + &config.file_dir, + ).await; + Some(resp) + } + + _ => Some("Unknown command. Type /help".into()), + } +} + +// ─── Admin console ──────────────────────────────────────────────────────────── + +async fn admin_console( + state_lock: SharedState, + file_dir: String, + delete_on_shutdown: bool, + plugin_manager: SharedPluginManager, +) { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + println!(); + + loop { + let cmd = tokio::task::spawn_blocking(|| { + use std::io::Write; + print!(">> "); + std::io::stdout().flush().ok(); + let mut line = String::new(); + std::io::stdin().read_line(&mut line).ok(); + line.trim().to_string() + }).await.unwrap_or_default(); + + if cmd.is_empty() { continue; } + + if let Some(rest) = cmd.strip_prefix("/create ") { + let name = rest.trim(); + let mut state = state_lock.write().await; + println!("{}", create_channel(&mut state, name)); + } else if let Some(rest) = cmd.strip_prefix("/delete ") { + let name = rest.trim(); + let mut state = state_lock.write().await; + println!("{}", delete_channel(&mut state, name)); + } else if cmd == "/list" { + let state = state_lock.read().await; + let list = state.channels.keys().map(|k| format!("#{}", k)).collect::>().join("\n"); + println!("Channels:\n{}", list); + } else if cmd == "/plugins" { + let pm = plugin_manager.lock().await; + let info = pm.get_plugin_info(); + if info.is_empty() { + println!("No plugins loaded."); + } else { + println!("Loaded plugins:"); + for p in info { + let cmds = if p.commands.is_empty() { "none".to_string() } else { p.commands.join(", ") }; + println!(" * {} (loaded at {}) - commands: {}", p.name, p.loaded_at, cmds); + } + } + } else if cmd == "/reload_plugins" { + let mut pm = plugin_manager.lock().await; + pm.reload_all_plugins(); + println!("All plugins reloaded."); + } else if let Some(rest) = cmd.strip_prefix("/reload_plugin ") { + let name = rest.trim(); + let mut pm = plugin_manager.lock().await; + if pm.reload_plugin(name) { + println!("Plugin '{}' reloaded.", name); + } else { + println!("Failed to reload plugin '{}'.", name); + } + } else if let Some(rest) = cmd.strip_prefix("/load_plugin ") { + let name = rest.trim(); + let mut pm = plugin_manager.lock().await; + if pm.load_plugin(name) { + println!("Plugin '{}' loaded.", name); + } else { + println!("Failed to load plugin '{}'.", name); + } + } else if let Some(rest) = cmd.strip_prefix("/unload_plugin ") { + let name = rest.trim(); + let mut pm = plugin_manager.lock().await; + if pm.unload_plugin(name) { + println!("Plugin '{}' unloaded.", name); + } else { + println!("Plugin '{}' not found.", name); + } + } else if cmd == "/exit" { + println!("Shutting down server."); + if delete_on_shutdown && Path::new(&file_dir).is_dir() { + println!("[+] Cleaning up folder {}", file_dir); + let _ = fs::remove_dir_all(&file_dir); + } + std::process::exit(0); + } else if cmd == "/help" { + println!("Commands: /create , /delete , /list, /plugins, /reload_plugins, /reload_plugin , /load_plugin , /unload_plugin , /exit"); + } else { + println!("Unknown command. Type /help"); + } + } +} + +// ─── Background transfer cleanup task ──────────────────────────────────────── + +async fn cleanup_loop(state_lock: SharedState) { + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(CLEANUP_INTERVAL_SECS)).await; + let mut state = state_lock.write().await; + state.cleanup_old_transfers(); + + let to_remove: Vec = state.ip_to_client.iter() + .filter(|(_ip, &client_id)| !state.clients.contains_key(&client_id)) + .map(|(ip, _)| ip.clone()) + .collect(); + + for ip in to_remove { + state.ip_to_client.remove(&ip); + } + } +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + // Explicitly set ring as TLS provider (before any rustls usage) + rustls::crypto::ring::default_provider() + .install_default() + .expect("[!] Failed to install rustls CryptoProvider"); + + let config = Arc::new(load_config()); + + // Initialize DB and channels + init_db(); + let channels = load_channels(); + + // Initialize server state + let state = Arc::new(RwLock::new({ + let mut s = ServerState::new(); + s.channels = channels; + s + })); + + // Check certificate files + let cert_exists = Path::new(&config.server_pem).exists() + || (Path::new(&config.cert_file).exists() && Path::new(&config.key_file).exists()); + if !cert_exists { + eprintln!("[!] TLS certificates not found!"); + eprintln!("[!] Check: {}, {} or {}", config.cert_file, config.key_file, config.server_pem); + std::process::exit(1); + } + + if config.require_client_cert && !Path::new(&config.ca_file).exists() { + eprintln!("[!] Client certificate (for verification) not found: {}", config.ca_file); + eprintln!("[!] Please set 'ca_file' in config.json"); + std::process::exit(1); + } + + // Load TLS + let tls_config = load_tls_config(&config); + let acceptor = TlsAcceptor::from(tls_config); + + // Create file storage directory + let _ = fs::create_dir_all(&config.file_dir); + + // Initialize plugin system + let (server_op_tx, server_op_rx) = tokio::sync::mpsc::unbounded_channel::(); + let plugin_manager: SharedPluginManager = Arc::new(Mutex::new( + PluginManager::new(state.clone(), server_op_tx) + )); + // Start handler for operations from Lua plugins + tokio::spawn(plugins::run_server_op_handler(server_op_rx, state.clone())); + // Create plugin data directory + let _ = fs::create_dir_all("plugins/plugins_data"); + // Load plugins from plugins.cfg + { + let mut pm = plugin_manager.lock().await; + pm.load_plugins_from_config(); + } + + // Start file server + if config.enable_file_server { + let file_host = config.ip.clone(); + let file_port = config.file_server_port; + let file_acceptor = acceptor.clone(); + let file_state = state.clone(); + let file_dir = config.file_dir.clone(); + let file_pm = plugin_manager.clone(); + tokio::spawn(async move { + run_file_server(file_host, file_port, file_acceptor, file_state, file_dir, file_pm).await; + }); + println!("[+] File server started on port {}", config.file_server_port); + } else { + println!("[!] File server (PNFT) disabled."); + } + + // Start transfer cleanup task + tokio::spawn(cleanup_loop(state.clone())); + + // Start chat server + let listener = TcpListener::bind(format!("{}:{}", config.ip, config.port)) + .await + .expect("[!] Cannot bind chat server"); + + println!("Chat server started on {}:{}", config.ip, config.port); + + if config.require_client_cert { + println!("[!] IMPORTANT: Clients must present certificate from: {}", config.ca_file); + } + println!("Server version: {}", SERVER_VERSION); + + // Admin console + let console_state = state.clone(); + let console_file_dir = config.file_dir.clone(); + let delete_on_shutdown = config.delete_files_on_shutdown; + let console_pm = plugin_manager.clone(); + tokio::spawn(async move { + admin_console(console_state, console_file_dir, delete_on_shutdown, console_pm).await; + }); + + // Accept incoming connections + loop { + match listener.accept().await { + Ok((stream, addr)) => { + let acc = acceptor.clone(); + let st = state.clone(); + let cfg = config.clone(); + let pm = plugin_manager.clone(); + tokio::spawn(async move { + handle_client(stream, addr, acc, st, cfg, pm).await; + }); + } + Err(e) => { + eprintln!("[!] Accept error: {}", e); + } + } + } +}