diff --git a/Cargo.lock b/Cargo.lock index 92f53d3..0181678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - [[package]] name = "async-trait" version = "0.1.92" @@ -19,7 +10,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -28,35 +19,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.7.9" @@ -112,28 +74,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-server" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" -dependencies = [ - "arc-swap", - "bytes", - "fs-err", - "http", - "http-body", - "hyper", - "hyper-util", - "pin-project-lite", - "rustls", - "rustls-pemfile", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "base64" version = "0.22.1" @@ -167,12 +107,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -186,8 +120,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -197,15 +129,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -225,78 +148,12 @@ dependencies = [ "typenum", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - [[package]] name = "deranged" version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -315,27 +172,9 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" - -[[package]] -name = "equivalent" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" - [[package]] name = "errno" version = "0.3.14" @@ -352,12 +191,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -367,22 +200,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-err" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" -dependencies = [ - "autocfg", - "tokio", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures-channel" version = "0.3.34" @@ -398,6 +215,23 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -417,7 +251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -443,53 +281,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "getset" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "h2" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "hex" version = "0.4.3" @@ -549,9 +340,7 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -685,12 +474,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -713,13 +496,13 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "2.14.2" +name = "if-addrs" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" dependencies = [ - "equivalent", - "hashbrown", + "libc", + "windows-sys 0.59.0", ] [[package]] @@ -734,16 +517,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.106" @@ -767,17 +540,6 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -[[package]] -name = "local-ip-address" -version = "0.6.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" -dependencies = [ - "libc", - "neli", - "windows-sys 0.61.2", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -822,35 +584,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "neli" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" -dependencies = [ - "bitflags", - "byteorder", - "derive_builder", - "getset", - "libc", - "log", - "neli-proc-macros", - "parking_lot", -] - -[[package]] -name = "neli-proc-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" -dependencies = [ - "either", - "proc-macro2", - "quote", - "serde", - "syn 2.0.119", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -908,12 +641,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - [[package]] name = "potential_utf" version = "0.1.6" @@ -947,21 +674,19 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "ramshare_ui" -version = "0.3.0" +version = "0.4.0" dependencies = [ "axum", - "axum-server", - "bytes", + "futures-util", + "getrandom", "hex", - "local-ip-address", + "hyper", + "hyper-util", + "if-addrs", + "parking_lot", + "percent-encoding", "rcgen", "reqwest", "rustls", @@ -970,6 +695,9 @@ dependencies = [ "sha2", "socket2 0.5.10", "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", ] [[package]] @@ -1003,6 +731,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1021,12 +750,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -1038,7 +769,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", @@ -1050,7 +781,6 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ - "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1059,15 +789,6 @@ dependencies = [ "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.15.1" @@ -1083,7 +804,6 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1134,7 +854,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -1238,29 +958,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "3.0.9" @@ -1289,7 +992,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -1330,7 +1033,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2 0.6.5", @@ -1346,7 +1048,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] @@ -1359,6 +1061,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -1368,7 +1081,6 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "libc", "pin-project-lite", "tokio", ] @@ -1545,7 +1257,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn", "wasm-bindgen-shared", ] @@ -1558,6 +1270,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -1583,6 +1308,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1690,7 +1424,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", "synstructure", ] @@ -1711,7 +1445,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", "synstructure", ] @@ -1751,7 +1485,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e149dc0..1d0f2bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,37 @@ [package] name = "ramshare_ui" -version = "0.3.0" +version = "0.4.0" edition = "2021" [dependencies] -tokio = { version = "1.40", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "time", "sync", "io-util", "signal"] } +tokio-stream = "0.1" +tokio-util = { version = "0.7", features = ["io"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +futures-util = "0.3" + axum = { version = "0.7" } -axum-server = { version = "0.7", features = ["tls-rustls"] } -bytes = "1.7" -socket2 = "0.5" - -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-no-provider", "json"] } +hyper = { version = "1", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio", "server", "http1", "service"] } +# 0.12.9+: нужен ClientBuilder::read_timeout / tls_info / use_preconfigured_tls +reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls-no-provider", "stream"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } # TLS 1.2 не нужен rcgen = "0.13" -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } sha2 = "0.10" hex = "0.4" +getrandom = "0.2" +percent-encoding = "2" -local-ip-address = "0.6" +socket2 = "0.5" +if-addrs = "0.13" +parking_lot = "0.12" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = "fat" +codegen-units = 1 +strip = true diff --git a/src/main.rs b/src/main.rs index 412a8fd..af90a12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,326 +1,214 @@ mod models; +mod net; mod p2p; +mod storage; mod tls; mod ui; use axum::{ extract::DefaultBodyLimit, + middleware, routing::{get, post}, Router, }; -use axum_server::tls_rustls::RustlsConfig; -use local_ip_address::local_ip; -use models::{ - now_sec, AppState, BeaconPacket, ScanProgress, DISCOVERY_PORT, MAX_PAYLOAD_SIZE, - MAX_RAM_TEXTS, MEDIA_DIR, -}; -use socket2::{Domain, Protocol, Socket, Type}; +use models::*; +use parking_lot::{Mutex as PlMutex, RwLock}; use std::{ collections::{HashMap, VecDeque}, - net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}, - sync::{Arc, RwLock}, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, time::Duration, }; -use tokio::net::UdpSocket; +use tokio::sync::{Mutex, Semaphore}; -fn detect_lan_ip() -> String { - if let Ok(sock) = StdUdpSocket::bind("0.0.0.0:0") { - if sock.connect("8.8.8.8:80").is_ok() { - if let Ok(local_addr) = sock.local_addr() { - let ip = local_addr.ip().to_string(); - if !ip.starts_with("127.") && !ip.starts_with("172.17.") { - return ip; - } - } - } - } - local_ip() - .map(|addr| addr.to_string()) - .unwrap_or_else(|_| "127.0.0.1".to_string()) +const USAGE: &str = "\ +Использование: ramshare_ui [параметры] + --name <имя> имя устройства + --p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443 + --ui-port <порт> порт локального UI (только 127.0.0.1), по умолчанию 8080 + --ip адрес интерфейса (по умолчанию определяется автоматически) + --data-dir <путь> каталог данных (идентичность, доверенные, принятые файлы) + -h, --help эта справка"; + +struct Config { + name: String, + p2p_port: u16, + ui_port: u16, + ip: Option, + data_dir: Option, } -fn subnet_broadcast_addr(ip_str: &str, port: u16) -> Option { - let ipv4: Ipv4Addr = ip_str.parse().ok()?; - let octets = ipv4.octets(); - Some(format!("{}.{}.{}.255:{}", octets[0], octets[1], octets[2], port)) +fn fatal(msg: &str) -> ! { + eprintln!("Ошибка: {}", msg); + std::process::exit(1); } -pub async fn ping_all_peers(state: &Arc) { - let targets: Vec<(String, Vec, u16)> = { - let guard = state.peers.read().unwrap(); - guard - .values() - .map(|p| (p.id.clone(), p.ips.clone(), p.port)) - .collect() +fn parse_args() -> Result { + let default_name = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "My-Device".into()); + let mut cfg = Config { + name: clean_label(&default_name, 48), + p2p_port: 8443, + ui_port: 8080, + ip: None, + data_dir: None, }; - - for (peer_id, ips, port) in targets { - let mut reachable = false; - for ip in ips { - let url = format!("https://{}:{}/p2p/info", ip, port); - let res = state - .probe_client - .get(&url) - .header("X-Node-Fp", &state.my_fingerprint) - .header("X-Node-Name", &state.device_name) - .header("X-Node-Ip", &state.my_ip) - .header("X-Node-Port", state.p2p_port.to_string()) - .send() - .await; - - if let Ok(resp) = res { - if resp.status().is_success() { - if let Ok(info) = resp.json::().await { - let name = info["name"].as_str().unwrap_or("Node").to_string(); - let fp = info["fingerprint"].as_str().unwrap_or("").to_string(); - if !fp.is_empty() { - state.upsert_peer(name, ip, port, fp); - reachable = true; - break; - } - } - } + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag)); + match a.as_str() { + "--name" => cfg.name = clean_label(&val("--name")?, 48), + "--p2p-port" => { + cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())? } - } - - if !reachable { - let mut guard = state.peers.write().unwrap(); - if let Some(peer) = guard.get_mut(&peer_id) { - if now_sec().saturating_sub(peer.last_seen) > 15 { - peer.online = false; - } + "--ui-port" => { + cfg.ui_port = val("--ui-port")?.parse().map_err(|_| "Некорректный --ui-port".to_string())? } + "--ip" => cfg.ip = Some(val("--ip")?.parse().map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?), + "--data-dir" => cfg.data_dir = Some(PathBuf::from(val("--data-dir")?)), + "-h" | "--help" => { + println!("{}", USAGE); + std::process::exit(0); + } + other => return Err(format!("Неизвестный аргумент: {}\n\n{}", other, USAGE)), } } -} - -fn start_udp_discovery(state: Arc) { - let tx_state = state.clone(); - tokio::spawn(async move { - let bind_target = format!("{}:0", tx_state.my_ip); - let sender = match UdpSocket::bind(&bind_target).await { - Ok(s) => s, - Err(_) => match UdpSocket::bind("0.0.0.0:0").await { - Ok(s) => s, - Err(e) => { - tx_state.add_log(format!("Ошибка открытия UDP сокета отправки: {}", e)); - return; - } - }, - }; - let _ = sender.set_broadcast(true); - - let global_bcast = format!("255.255.255.255:{}", DISCOVERY_PORT); - let subnet_bcast = subnet_broadcast_addr(&tx_state.my_ip, DISCOVERY_PORT); - - loop { - let packet = BeaconPacket { - name: tx_state.device_name.clone(), - ip: tx_state.my_ip.clone(), - port: tx_state.p2p_port, - fingerprint: tx_state.my_fingerprint.clone(), - }; - - if let Ok(bytes) = serde_json::to_vec(&packet) { - if let Some(ref sub_target) = subnet_bcast { - let _ = sender.send_to(&bytes, sub_target).await; - } - let _ = sender.send_to(&bytes, &global_bcast).await; - } - tokio::time::sleep(Duration::from_secs(3)).await; - } - }); - - tokio::spawn(async move { - let bind_addr: SocketAddr = format!("0.0.0.0:{}", DISCOVERY_PORT).parse().unwrap(); - let socket = match Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) { - Ok(s) => s, - Err(_) => return, - }; - - let _ = socket.set_reuse_address(true); - let _ = socket.set_broadcast(true); - let _ = socket.set_nonblocking(true); - - if let Err(e) = socket.bind(&bind_addr.into()) { - state.add_log(format!("UDP порт {} занят ({})", DISCOVERY_PORT, e)); - return; - } - - let std_sock: StdUdpSocket = socket.into(); - let receiver = match UdpSocket::from_std(std_sock) { - Ok(s) => s, - Err(_) => return, - }; - - let mut buf = [0u8; 2048]; - loop { - match receiver.recv_from(&mut buf).await { - Ok((len, remote_addr)) => { - if let Ok(beacon) = serde_json::from_slice::(&buf[..len]) { - if beacon.fingerprint == state.my_fingerprint { - continue; - } - let peer_ip = if !beacon.ip.is_empty() && beacon.ip != "0.0.0.0" { - beacon.ip - } else { - remote_addr.ip().to_string() - }; - state.upsert_peer(beacon.name, peer_ip, beacon.port, beacon.fingerprint); - } - } - Err(_) => { - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } - }); + if cfg.name.is_empty() { + return Err("Пустое имя устройства".into()); + } + if cfg.p2p_port == 0 || cfg.ui_port == 0 { + return Err("Порт не может быть 0".into()); + } + Ok(cfg) } #[tokio::main] async fn main() { let _ = rustls::crypto::ring::default_provider().install_default(); - let mut device_name = "My-Device".to_string(); - let mut p2p_port: u16 = 8443; - let mut ui_port: u16 = 8080; - let mut custom_ip: Option = None; + let cfg = parse_args().unwrap_or_else(|e| fatal(&e)); - let args: Vec = std::env::args().collect(); - let mut i = 1; - let mut positional_idx = 0; - - while i < args.len() { - match args[i].as_str() { - "--ip" => { - if i + 1 < args.len() { - custom_ip = Some(args[i + 1].clone()); - i += 1; - } - } - "--name" => { - if i + 1 < args.len() { - device_name = args[i + 1].clone(); - i += 1; - } - } - "--p2p-port" => { - if i + 1 < args.len() { - if let Ok(p) = args[i + 1].parse() { - p2p_port = p; - } - i += 1; - } - } - "--ui-port" => { - if i + 1 < args.len() { - if let Ok(p) = args[i + 1].parse() { - ui_port = p; - } - i += 1; - } - } - arg if !arg.starts_with("--") => { - match positional_idx { - 0 => device_name = arg.to_string(), - 1 => { - if let Ok(p) = arg.parse() { - p2p_port = p; - } - } - 2 => { - if let Ok(p) = arg.parse() { - ui_port = p; - } - } - 3 => custom_ip = Some(arg.to_string()), - _ => {} - } - positional_idx += 1; - } - _ => {} + // ── адреса ── + let (bind_ip, my_ip) = match cfg.ip { + Some(ip) if !ip.is_unspecified() => (ip, ip), + _ => { + let ip = net::detect_lan_ip().unwrap_or_else(|| { + eprintln!("[WARN] Локальный IP не определён, используется 127.0.0.1 (укажите --ip)"); + Ipv4Addr::LOCALHOST + }); + (Ipv4Addr::UNSPECIFIED, ip) } - i += 1; - } - - let (bind_ip, my_ip) = match custom_ip { - Some(ip) => { - if ip == "0.0.0.0" { - ("0.0.0.0".to_string(), detect_lan_ip()) - } else { - (ip.clone(), ip) - } - } - None => ("0.0.0.0".to_string(), detect_lan_ip()), }; - tokio::fs::create_dir_all(MEDIA_DIR) - .await - .expect("Не удалось создать папку received_media"); + // ── каталоги ── + let data_dir = { + let p = cfg.data_dir.clone().unwrap_or_else(|| storage::default_data_dir(cfg.p2p_port)); + if p.is_absolute() { + p + } else { + std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p) + } + }; + let media_dir = data_dir.join("received_media"); + std::fs::create_dir_all(&media_dir).unwrap_or_else(|e| fatal(&format!("создание {}: {}", media_dir.display(), e))); + storage::cleanup_partials(&media_dir); - let (cert_der, key_der, my_fingerprint) = tls::load_or_create_identity(&my_ip, p2p_port); - let peers = Arc::new(RwLock::new(HashMap::new())); - let http_client = tls::build_pinned_http_client(peers.clone()); - let probe_client = tls::build_probe_http_client(); + // ── идентичность и доверие ── + let identity = tls::load_or_create_identity(&data_dir, cfg.p2p_port).unwrap_or_else(|e| fatal(&e)); + + let mut known: HashMap = HashMap::new(); + for t in storage::load_trusted(&data_dir) { + if !is_valid_fp(&t.fp) || t.fp == identity.fingerprint { + continue; + } + let ips: Vec = t + .ips + .into_iter() + .filter(|s| s.parse::().is_ok()) + .take(MAX_PEER_IPS) + .collect(); + let port = if t.port == 0 { 8443 } else { t.port }; + known.insert( + t.fp.clone(), + PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true), + ); + } + let peers = Arc::new(RwLock::new(known)); + + let probe_client = tls::probe_client(&identity.cert_der, &identity.key_der).unwrap_or_else(|e| fatal(&e)); + let server_cfg = tls::server_config(&identity).unwrap_or_else(|e| fatal(&e)); + let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg); + + // ── слушатели: привязываем ДО запуска задач, чтобы ошибка порта была фатальной ── + let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port)) + .await + .unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e))); + let ui_listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port)) + .await + .unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))); let state = Arc::new(AppState { - device_name: device_name.clone(), - my_ip: my_ip.clone(), - p2p_port, - my_fingerprint: my_fingerprint.clone(), - ping_interval_sec: 5, + device_name: cfg.name.clone(), + my_ip: my_ip.to_string(), + p2p_port: cfg.p2p_port, + my_fingerprint: identity.fingerprint.clone(), + cert_der: identity.cert_der, + key_der: identity.key_der, + data_dir: data_dir.clone(), + media_dir: media_dir.clone(), ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)), - disk_media: RwLock::new(VecDeque::new()), + disk_media: RwLock::new(storage::scan_media_dir(&media_dir).into_iter().collect()), peers, - http_client, probe_client, transfers: RwLock::new(Vec::new()), scan_status: RwLock::new(ScanProgress::default()), logs: RwLock::new(VecDeque::new()), + incoming_slots: Arc::new(Semaphore::new(MAX_INCOMING)), + ping_lock: Mutex::new(()), + trust_dirty: AtomicBool::new(false), + probe_cooldown: PlMutex::new(HashMap::new()), }); state.add_log(format!( - "Узел '{}' запущен на {}:{} (PIN: {})", - device_name, + "Узел '{}' запущен на {}:{} (ID: {})", + cfg.name, my_ip, - p2p_port, - &my_fingerprint[..8].to_uppercase() + cfg.p2p_port, + short_id(&state.my_fingerprint) )); - start_udp_discovery(state.clone()); + net::start_discovery(state.clone()); - let ping_state = state.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(ping_state.ping_interval_sec)).await; - ping_all_peers(&ping_state).await; - } - }); + // периодический опрос + обслуживание (без наложения тиков) + { + let st = state.clone(); + tokio::spawn(async move { + let mut iv = tokio::time::interval(Duration::from_secs(5)); + iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + iv.tick().await; + net::ping_all_peers(&st).await; + st.maintenance(); + } + }); + } - let tls_config = RustlsConfig::from_der(vec![cert_der], key_der) - .await - .expect("Ошибка конфигурации TLS"); - - let p2p_router = Router::new() - .route("/p2p/info", get(p2p::p2p_info)) - .route("/p2p/text", post(p2p::p2p_receive_text)) - .route("/p2p/media", post(p2p::p2p_receive_media)) - .layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE)) - .with_state(state.clone()); - - let p2p_addr: SocketAddr = format!("{}:{}", bind_ip, p2p_port) - .parse() - .unwrap_or_else(|_| panic!("Некорректный адрес P2P: {}:{}", bind_ip, p2p_port)); - - tokio::spawn(async move { - axum_server::bind_rustls(p2p_addr, tls_config) - .serve(p2p_router.into_make_service()) - .await - .expect("Ошибка P2P HTTPS-сервера"); + // ── UI ── + let guard = Arc::new(ui::UiGuard { + token: random_hex(16), + cookie_name: format!("rs_token_{}", cfg.ui_port), + hosts: vec![ + format!("127.0.0.1:{}", cfg.ui_port), + format!("localhost:{}", cfg.ui_port), + ], }); + let ui_url = format!("http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token); let ui_router = Router::new() .route("/", get(ui::ui_index)) + .route("/app.js", get(ui::ui_js)) .route("/ui/state", get(ui::ui_get_state)) .route("/ui/manual-peer", post(ui::ui_add_manual_peer)) .route("/ui/scan-range", post(ui::ui_scan_range)) @@ -328,31 +216,32 @@ async fn main() { .route("/ui/trust", post(ui::ui_trust_peer)) .route("/ui/clear-ram", post(ui::ui_clear_ram)) .route("/ui/send-text", post(ui::ui_send_text)) - .route("/ui/send-media", post(ui::ui_send_media)) + .route("/ui/send-media", post(ui::ui_send_media)) // сырой Body — без лимита экстрактора .route("/ui/files/:filename", get(ui::ui_serve_saved_file)) - .layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE)) - .with_state(state); - - let ui_addr: SocketAddr = format!("127.0.0.1:{}", ui_port) - .parse() - .expect("Некорректный порт UI"); + .layer(DefaultBodyLimit::max(512 * 1024)) // для JSON-эндпоинтов + .layer(middleware::from_fn_with_state(guard, ui::ui_guard)) + .with_state(state.clone()); println!("=============================================================="); - println!(" Устройство: {}", device_name); - println!(" P2P сокет (HTTPS): https://{}:{}", bind_ip, p2p_port); - println!(" Сетевой IP в локальной сети: {}", my_ip); - println!( - " ПОСТОЯННЫЙ PIN УСТРОЙСТВА: [ {} ]", - &my_fingerprint[..8].to_uppercase() - ); - println!(" Файл постоянной идентичности: .identity_{}.json", p2p_port); - println!(" Папка для медиа на диске: ./{}/", MEDIA_DIR); + println!(" Устройство: {}", cfg.name); + println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port); + println!(" Адрес в сети: {}", my_ip); + println!(" ID устройства: {}", short_id(&state.my_fingerprint)); + println!(" Каталог данных: {}", data_dir.display()); println!("--------------------------------------------------------------"); - println!(" ОТКРОЙТЕ ИНТЕРФЕЙС В БРАУЗЕРЕ: http://127.0.0.1:{}", ui_port); + println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):"); + println!(" {}", ui_url); println!("=============================================================="); - let listener = tokio::net::TcpListener::bind(ui_addr) - .await - .expect("Не удалось открыть порт локального UI"); - axum::serve(listener, ui_router).await.unwrap(); + let ui_task = tokio::spawn(async move { axum::serve(ui_listener, ui_router).await }); + let p2p_task = tokio::spawn(p2p::serve(p2p_listener, acceptor, p2p::router(state.clone()))); + + tokio::select! { + r = ui_task => eprintln!("UI-сервер остановился: {:?}", r), + r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r), + _ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"), + } + + state.persist_trust(); + storage::cleanup_partials(&media_dir); } diff --git a/src/models.rs b/src/models.rs index 703e7bd..85cbedd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,24 +1,59 @@ -use serde::{Deserialize, Serialize}; -use std::{ - collections::{HashMap, VecDeque}, - net::Ipv4Addr, - sync::{Arc, RwLock}, - time::{SystemTime, UNIX_EPOCH}, +use crate::storage::{self, TrustedEntry}; +use axum::{ + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, }; +use parking_lot::{Mutex as PlMutex, RwLock}; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{hash_map::Entry, BTreeSet, HashMap, VecDeque}, + fmt::Display, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::{Mutex, Semaphore}; -pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024; +// ───────────────────────── Лимиты ───────────────────────── +pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // один файл +pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024; // всего в received_media +pub const MAX_TEXT_BYTES: usize = 64 * 1024; pub const MAX_RAM_TEXTS: usize = 50; pub const MAX_LOGS: usize = 100; -pub const MEDIA_DIR: &str = "received_media"; +pub const MAX_PEERS: usize = 256; +pub const MAX_PEER_IPS: usize = 4; +pub const MAX_TRANSFERS: usize = 20; +pub const MAX_INCOMING: usize = 4; +pub const MAX_MEDIA_LIST: usize = 500; pub const DISCOVERY_PORT: u16 = 5354; -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SavedIdentity { - pub cert_hex: String, - pub key_hex: String, - pub fingerprint: String, +// ───────────────────────── Ошибки ───────────────────────── +pub struct AppError(pub StatusCode, pub String); + +impl AppError { + pub fn new(status: StatusCode, msg: impl Into) -> Self { + Self(status, msg.into()) + } + /// Детали пишем в stderr, наружу отдаём общий текст (без путей и внутренностей). + pub fn internal(ctx: &str, e: impl Display) -> Self { + eprintln!("[ERR] {}: {}", ctx, e); + Self(StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка".into()) + } } +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.0, self.1).into_response() + } +} + +// ───────────────────────── DTO ───────────────────────── #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TextItem { pub sender: String, @@ -32,8 +67,10 @@ pub struct MediaItem { pub filename: String, pub saved_path: String, pub mime: String, - pub size: usize, + pub size: u64, pub timestamp: u64, + pub previewable: bool, + pub risky: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -43,18 +80,34 @@ pub struct PeerDevice { pub ips: Vec, pub port: u16, pub fingerprint: String, + /// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков. pub pin_code: String, pub verified: bool, pub online: bool, pub last_seen: u64, } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BeaconPacket { - pub name: String, - pub ip: String, - pub port: u16, - pub fingerprint: String, +impl PeerDevice { + pub fn new(fp: &str, name: String, ips: Vec, port: u16, my_fp: &str, verified: bool) -> Self { + Self { + id: fp.to_string(), + name, + ips, + port, + fingerprint: fp.to_string(), + pin_code: sas_code(my_fp, fp), + verified, + online: false, + last_seen: 0, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + In, + Out, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -62,10 +115,9 @@ pub struct TransferProgress { pub id: String, pub filename: String, pub peer_name: String, - /// "out" (отправка) или "in" (прием) - pub direction: String, - pub transferred_bytes: usize, - pub total_bytes: usize, + pub direction: Direction, + pub transferred_bytes: u64, + pub total_bytes: u64, pub percent: u8, pub done: bool, pub error: Option, @@ -81,99 +133,8 @@ pub struct ScanProgress { pub found: usize, } -pub struct AppState { - pub device_name: String, - pub my_ip: String, - pub p2p_port: u16, - pub my_fingerprint: String, - pub ping_interval_sec: u64, - pub ram_texts: RwLock>, - pub disk_media: RwLock>, - pub peers: Arc>>, - pub http_client: reqwest::Client, - pub probe_client: reqwest::Client, - pub transfers: RwLock>, - pub scan_status: RwLock, - pub logs: RwLock>, -} - -impl AppState { - pub fn add_log(&self, msg: impl Into) { - let text = msg.into(); - println!("[LOG] {}", text); - let mut logs = self.logs.write().unwrap(); - if logs.len() >= MAX_LOGS { - logs.pop_back(); - } - logs.push_front(format!("[{}] {}", now_sec(), text)); - } - - pub fn upsert_transfer(&self, item: TransferProgress) { - let mut list = self.transfers.write().unwrap(); - if let Some(existing) = list.iter_mut().find(|t| t.id == item.id) { - *existing = item; - } else { - list.insert(0, item); - } - } - - pub fn remove_transfer(&self, id: &str) { - let mut list = self.transfers.write().unwrap(); - list.retain(|t| t.id != id); - } - - pub fn upsert_peer(&self, name: String, ip: String, port: u16, fingerprint: String) -> bool { - if fingerprint == self.my_fingerprint || fingerprint.len() < 8 { - return false; - } - if ip.is_empty() || ip == "0.0.0.0" || ip == "127.0.0.1" { - return false; - } - - let pin_code = fingerprint[..8].to_uppercase(); - let now = now_sec(); - - let mut peers = self.peers.write().unwrap(); - if let Some(existing) = peers.get_mut(&fingerprint) { - existing.name = name; - existing.port = port; - existing.online = true; - existing.last_seen = now; - if let Some(pos) = existing.ips.iter().position(|x| x == &ip) { - existing.ips.remove(pos); - } - existing.ips.insert(0, ip); - false - } else { - drop(peers); - self.add_log(format!( - "Обнаружено устройство '{}' ({}:{}) PIN: [{}]", - name, ip, port, pin_code - )); - let mut peers = self.peers.write().unwrap(); - peers.insert( - fingerprint.clone(), - PeerDevice { - id: fingerprint.clone(), - name, - ips: vec![ip], - port, - fingerprint, - pin_code, - verified: false, - online: true, - last_seen: now, - }, - ); - true - } - } -} - #[derive(Serialize, Deserialize)] pub struct IncomingTextPayload { - pub sender: String, - pub sender_fp: String, pub text: String, } @@ -201,6 +162,240 @@ pub struct UiScanRangeRequest { pub port: Option, } +// ───────────────────────── Состояние ───────────────────────── +pub struct AppState { + pub device_name: String, + pub my_ip: String, + pub p2p_port: u16, + pub my_fingerprint: String, + pub cert_der: Vec, + pub key_der: Vec, + pub data_dir: PathBuf, + pub media_dir: PathBuf, + pub ram_texts: RwLock>, + pub disk_media: RwLock>, + pub peers: Arc>>, + pub probe_client: reqwest::Client, + pub transfers: RwLock>, + pub scan_status: RwLock, + pub logs: RwLock>, + pub incoming_slots: Arc, + pub ping_lock: Mutex<()>, + pub trust_dirty: AtomicBool, + pub probe_cooldown: PlMutex>, +} + +impl AppState { + pub fn add_log(&self, msg: impl Into) { + // защита от log-injection: управляющие символы в именах и т.п. + let text: String = msg + .into() + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + println!("[LOG] {}", text); + let mut logs = self.logs.write(); + if logs.len() >= MAX_LOGS { + logs.pop_back(); + } + logs.push_front(format!("[{}] {}", now_sec(), text)); + } + + // ── передачи ── + pub fn add_transfer(&self, t: TransferProgress) { + let mut l = self.transfers.write(); + l.insert(0, t); + l.truncate(MAX_TRANSFERS); + } + + pub fn update_transfer_bytes(&self, id: &str, bytes: u64) { + let mut l = self.transfers.write(); + if let Some(t) = l.iter_mut().find(|t| t.id == id) { + t.transferred_bytes = bytes; + t.percent = percent(bytes, t.total_bytes); + t.updated_at = now_sec(); + } + } + + pub fn finish_transfer(&self, id: &str) { + let mut l = self.transfers.write(); + if let Some(t) = l.iter_mut().find(|t| t.id == id) { + t.transferred_bytes = t.total_bytes; + t.percent = 100; + t.done = true; + t.updated_at = now_sec(); + } + } + + pub fn fail_transfer(&self, id: &str, msg: String) { + let mut l = self.transfers.write(); + if let Some(t) = l.iter_mut().find(|t| t.id == id) { + t.done = true; + t.error = Some(msg); + t.updated_at = now_sec(); + } + } + + pub fn remove_transfer(&self, id: &str) { + self.transfers.write().retain(|t| t.id != id); + } + + // ── доверие ── + pub fn trusted_name(&self, fp: &str) -> Option { + self.peers + .read() + .get(fp) + .filter(|p| p.verified) + .map(|p| p.name.clone()) + } + + pub fn persist_trust(&self) { + let entries: Vec = self + .peers + .read() + .values() + .filter(|p| p.verified) + .map(|p| TrustedEntry { + fp: p.id.clone(), + name: p.name.clone(), + ips: p.ips.clone(), + port: p.port, + }) + .collect(); + if let Err(e) = storage::save_trusted(&self.data_dir, &entries) { + self.add_log(format!("Не удалось сохранить список доверенных: {}", e)); + } + } + + pub fn client_for_peer(&self, fp: &str, streaming: bool) -> Result { + crate::tls::pinned_client( + fp.to_string(), + self.peers.clone(), + &self.cert_der, + &self.key_der, + streaming, + ) + .map_err(|e| AppError::internal("tls client", e)) + } + + // ── пиры ── + /// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из TLS-рукопожатия. + pub fn upsert_peer(&self, name: &str, ip: IpAddr, port: u16, fp: &str) -> bool { + if fp == self.my_fingerprint || !is_valid_fp(fp) || port == 0 { + return false; + } + if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() { + return false; + } + let mut name = clean_label(name, 48); + if name.is_empty() { + name = "Unknown".into(); + } + let ip_s = ip.to_string(); + let now = now_sec(); + let mut created = false; + let mut dirty = false; + + { + let mut peers = self.peers.write(); + if !peers.contains_key(fp) && peers.len() >= MAX_PEERS { + let victim = peers + .values() + .filter(|p| !p.verified && !p.online) + .min_by_key(|p| p.last_seen) + .map(|p| p.id.clone()); + match victim { + Some(v) => { + peers.remove(&v); + } + None => return false, + } + } + match peers.entry(fp.to_string()) { + Entry::Occupied(mut e) => { + let p = e.get_mut(); + let changed = p.ips.first() != Some(&ip_s) || p.port != port; + p.name = name.clone(); + p.port = port; + p.online = true; + p.last_seen = now; + p.ips.retain(|x| x != &ip_s); + p.ips.insert(0, ip_s.clone()); + p.ips.truncate(MAX_PEER_IPS); + if p.verified && changed { + dirty = true; + } + } + Entry::Vacant(v) => { + let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false); + p.online = true; + p.last_seen = now; + v.insert(p); + created = true; + } + } + } + + if dirty { + self.trust_dirty.store(true, Ordering::Relaxed); + } + if created { + self.add_log(format!( + "Обнаружено устройство '{}' ({}:{}), код сверки: {}", + name, + ip, + port, + sas_code(&self.my_fingerprint, fp) + )); + } + created + } + + /// Пир уже недавно виден по этому адресу — повторный probe по beacon не нужен. + pub fn is_fresh(&self, fp: &str, ip: IpAddr) -> bool { + let ip_s = ip.to_string(); + self.peers.read().get(fp).map_or(false, |p| { + p.online + && p.ips.first().map(String::as_str) == Some(ip_s.as_str()) + && now_sec().saturating_sub(p.last_seen) < 10 + }) + } + + pub fn probe_allowed(&self, ip: IpAddr) -> bool { + let mut m = self.probe_cooldown.lock(); + let now = Instant::now(); + if m.len() > 512 { + m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(30)); + } + match m.get(&ip) { + Some(t) if now.duration_since(*t) < Duration::from_secs(10) => false, + _ => { + m.insert(ip, now); + true + } + } + } + + pub fn maintenance(&self) { + let now = now_sec(); + self.peers + .write() + .retain(|_, p| p.verified || p.online || now.saturating_sub(p.last_seen) < 600); + if self.trust_dirty.swap(false, Ordering::Relaxed) { + self.persist_trust(); + } + } +} + +pub fn remove_transfer_later(state: &Arc, id: String, secs: u64) { + let st = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(secs)).await; + st.remove_transfer(&id); + }); +} + +// ───────────────────────── Утилиты ───────────────────────── pub fn now_sec() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -208,132 +403,246 @@ pub fn now_sec() -> u64 { .as_secs() } -pub fn url_decode(input: &str) -> String { - let mut bytes = Vec::with_capacity(input.len()); - let mut chars = input.as_bytes().iter().copied(); - while let Some(b) = chars.next() { - if b == b'%' { - if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) { - if let Ok(decoded) = - u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16) - { - bytes.push(decoded); - continue; - } - } - } else if b == b'+' { - bytes.push(b' '); - } else { - bytes.push(b); - } +pub fn percent(done: u64, total: u64) -> u8 { + if total == 0 { + 100 + } else { + ((done as u128 * 100) / total as u128).min(100) as u8 } - String::from_utf8_lossy(&bytes).into_owned() } +pub fn fingerprint(der: &[u8]) -> String { + hex::encode(Sha256::digest(der)) +} + +pub fn is_valid_fp(fp: &str) -> bool { + fp.len() == 64 && fp.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +pub fn short_id(fp: &str) -> String { + fp.get(..8).unwrap_or(fp).to_uppercase() +} + +/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа. +/// Подбор ключа «под префикс» не работает: код зависит от обоих сертификатов. +pub fn sas_code(a: &str, b: &str) -> String { + let (x, y) = if a <= b { (a, b) } else { (b, a) }; + let mut h = Sha256::new(); + h.update(b"ramshare-sas-v1"); + h.update(x.as_bytes()); + h.update(b"|"); + h.update(y.as_bytes()); + let digest = h.finalize(); + let s = hex::encode_upper(&digest[..10]); + s.as_bytes() + .chunks(4) + .map(|c| std::str::from_utf8(c).unwrap_or("")) + .collect::>() + .join("-") +} + +pub fn random_hex(n: usize) -> String { + let mut b = vec![0u8; n]; + getrandom::getrandom(&mut b).expect("ОС не предоставила случайные байты"); + hex::encode(b) +} + +/// Имя устройства и т.п.: без управляющих и bidi-символов, ограниченной длины. +pub fn clean_label(s: &str, max_chars: usize) -> String { + s.chars() + .filter(|c| { + !c.is_control() && !matches!(*c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}') + }) + .collect::() + .trim() + .chars() + .take(max_chars) + .collect() +} + +pub fn pct_decode(s: &str) -> String { + percent_decode_str(s).decode_utf8_lossy().into_owned() +} + +pub fn pct_encode(s: &str) -> String { + utf8_percent_encode(s, NON_ALPHANUMERIC).to_string() +} + +pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> { + h.get(name).and_then(|v| v.to_str().ok()) +} + +pub fn is_lan_v4(ip: Ipv4Addr) -> bool { + ip.is_private() || ip.is_link_local() +} + +/// Принимает уже раскодированное имя. Ничего не декодирует. pub fn sanitize_filename(name: &str) -> String { - let decoded = url_decode(name); - let clean: String = decoded + let clean: String = name .chars() .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_', - _ => c, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, }) .collect(); - let trimmed = clean.trim_matches('.').trim(); + let trimmed = clean.trim_matches(|c: char| c == '.' || c.is_whitespace()); if trimmed.is_empty() { - "media_file.bin".to_string() - } else { - trimmed.to_string() + return "file.bin".into(); + } + const MAX: usize = 120; + if trimmed.chars().count() <= MAX { + return trimmed.to_string(); + } + match trimmed.rfind('.') { + Some(i) if trimmed[i..].chars().count() <= 16 => { + let ext = &trimmed[i..]; + let keep = MAX - ext.chars().count(); + format!("{}{}", trimmed[..i].chars().take(keep).collect::(), ext) + } + _ => trimmed.chars().take(MAX).collect(), } } -/// Парсер диапазонов IP-адресов: -/// Поддерживает: "all", "192.168.1.1-254", "192.168.1.10-50", "192.168.1.10-192.168.1.80", "192.168.1.0/24" -pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec), String> { +/// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32), +/// одиночные адреса и списки через запятую. Только локальные (private/link-local) IPv4. +pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec), String> { + const MAX_TOTAL: usize = 1024; let raw = input.trim(); + let mine: Option = my_ip.parse().ok(); let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") { - let base: Ipv4Addr = my_ip - .parse() - .map_err(|_| "Не удалось определить подсеть автоматически. Введите диапазон вручную.".to_string())?; + let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?; let o = base.octets(); format!("{}.{}.{}.1-254", o[0], o[1], o[2]) } else { raw.to_string() }; + if effective.len() > 256 { + return Err("Слишком длинная строка диапазона".into()); + } - let mut result = Vec::new(); + let mut set: BTreeSet = BTreeSet::new(); + let mut tokens = 0; for part in effective.split(',') { let token = part.trim(); if token.is_empty() { continue; } + tokens += 1; + if tokens > 16 { + return Err("Слишком много элементов в списке (максимум 16)".into()); + } - if let Some(cidr_base) = token.strip_suffix("/24") { - let ip: Ipv4Addr = cidr_base - .trim() - .parse() - .map_err(|_| format!("Некорректный CIDR адрес: {}", token))?; - let o = ip.octets(); - for last in 1..=254u8 { - result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last)); + let (start, end): (u32, u32) = if let Some((base, pfx)) = token.split_once('/') { + let ip: Ipv4Addr = base.trim().parse().map_err(|_| format!("Некорректный CIDR адрес: {}", token))?; + let p: u32 = pfx.trim().parse().map_err(|_| format!("Некорректная маска: {}", token))?; + if !(22..=32).contains(&p) { + return Err("Поддерживаются маски от /22 до /32".into()); } + let mask: u32 = !0u32 << (32 - p); + let s = u32::from(ip) & mask; + (s, s | !mask) } else if let Some((left, right)) = token.split_once('-') { - let left = left.trim(); - let right = right.trim(); - let start_ip: Ipv4Addr = left - .parse() - .map_err(|_| format!("Некорректный начальный IP: {}", left))?; - - if right.contains('.') { - let end_ip: Ipv4Addr = right - .parse() - .map_err(|_| format!("Некорректный конечный IP: {}", right))?; - let start_u = u32::from(start_ip); - let end_u = u32::from(end_ip); - if end_u < start_u { - return Err("Конечный IP меньше начального".into()); - } - if end_u - start_u > 1024 { - return Err("Диапазон слишком велик (максимум 1024 адреса за раз)".into()); - } - for val in start_u..=end_u { - let ip = Ipv4Addr::from(val); - let last = ip.octets()[3]; - if last != 0 && last != 255 { - result.push(ip.to_string()); - } - } + let (left, right) = (left.trim(), right.trim()); + let s_ip: Ipv4Addr = left.parse().map_err(|_| format!("Некорректный начальный IP: {}", left))?; + let s = u32::from(s_ip); + let e = if right.contains('.') { + let e_ip: Ipv4Addr = right.parse().map_err(|_| format!("Некорректный конечный IP: {}", right))?; + u32::from(e_ip) } else { - let end_last: u8 = right - .parse() - .map_err(|_| format!("Некорректный конец диапазона: {}", right))?; - let o = start_ip.octets(); - let start_last = o[3]; - if end_last < start_last { - return Err(format!("Конец диапазона ({}) меньше начала ({})", end_last, start_last)); - } - for last in start_last..=end_last { - if last != 0 && last != 255 { - result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last)); - } - } + let last: u8 = right.parse().map_err(|_| format!("Некорректный конец диапазона: {}", right))?; + (s & !0xFF) | last as u32 + }; + if e < s { + return Err("Конец диапазона меньше начала".into()); } + (s, e) } else { - let ip: Ipv4Addr = token - .parse() - .map_err(|_| format!("Некорректный IP-адрес: {}", token))?; - result.push(ip.to_string()); + let ip: Ipv4Addr = token.parse().map_err(|_| format!("Некорректный IP-адрес: {}", token))?; + (u32::from(ip), u32::from(ip)) + }; + + if end - start > MAX_TOTAL as u32 { + return Err(format!("Диапазон слишком велик (максимум {} адресов)", MAX_TOTAL)); + } + for v in start..=end { + let ip = Ipv4Addr::from(v); + let last = ip.octets()[3]; + if start != end && (last == 0 || last == 255) { + continue; + } + if !is_lan_v4(ip) { + return Err(format!("Сканирование разрешено только в локальных сетях: {}", ip)); + } + set.insert(v); + if set.len() > MAX_TOTAL { + return Err(format!("Слишком много адресов (максимум {})", MAX_TOTAL)); + } } } - result.retain(|ip| ip != my_ip && ip != "127.0.0.1" && ip != "0.0.0.0"); - result.dedup(); + let result: Vec = set + .into_iter() + .map(Ipv4Addr::from) + .filter(|ip| Some(*ip) != mine && !ip.is_loopback() && !ip.is_unspecified()) + .collect(); if result.is_empty() { return Err("Диапазон не содержит доступных для сканирования IP-адресов".into()); } - Ok((effective, result)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn range_short_form() { + let (_, v) = parse_ip_range("192.168.1.10-12", "192.168.1.11").unwrap(); + assert_eq!(v.len(), 2); + } + + #[test] + fn range_rejects_public() { + assert!(parse_ip_range("8.8.8.8", "192.168.1.2").is_err()); + } + + #[test] + fn range_all_and_dedup() { + let (_, v) = parse_ip_range("all", "10.0.0.5").unwrap(); + assert_eq!(v.len(), 253); + let (_, v) = parse_ip_range("10.0.0.1,10.0.0.1,10.0.0.2", "10.0.0.5").unwrap(); + assert_eq!(v.len(), 2); + } + + #[test] + fn range_too_big() { + assert!(parse_ip_range("10.0.0.0/16", "10.0.0.5").is_err()); + } + + #[test] + fn filenames() { + assert_eq!(sanitize_filename("../../etc/passwd"), "_.._etc_passwd"); + assert_eq!(sanitize_filename("a+b%25.txt"), "a+b%25.txt"); + assert_eq!(sanitize_filename("..."), "file.bin"); + assert!(sanitize_filename(&"x".repeat(500)).chars().count() <= 120); + } + + #[test] + fn fp_validation() { + assert!(is_valid_fp(&"a".repeat(64))); + assert!(!is_valid_fp("x');alert(1)//")); + assert!(!is_valid_fp(&"A".repeat(64))); + } + + #[test] + fn sas_symmetric() { + let a = "a".repeat(64); + let b = "b".repeat(64); + assert_eq!(sas_code(&a, &b), sas_code(&b, &a)); + assert_eq!(sas_code(&a, &b).len(), 24); + } +} diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..2df83db --- /dev/null +++ b/src/net.rs @@ -0,0 +1,264 @@ +use crate::models::*; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}, + sync::Arc, + time::Duration, +}; +use tokio::{net::UdpSocket, sync::Semaphore}; + +const MAX_PROBE_BODY: usize = 8 * 1024; + +pub struct ProbeInfo { + pub fp: String, + pub name: String, +} + +// ───────────────────────── Интерфейсы ───────────────────────── +/// (ip, broadcast) всех локальных (private/link-local) IPv4-интерфейсов. +pub fn lan_interfaces() -> Vec<(Ipv4Addr, Option)> { + if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter_map(|i| match &i.addr { + if_addrs::IfAddr::V4(v4) if !i.is_loopback() && is_lan_v4(v4.ip) => Some((v4.ip, v4.broadcast)), + _ => None, + }) + .collect() +} + +/// Адрес интерфейса маршрута по умолчанию (UDP connect не отправляет пакетов и не требует интернета). +pub fn detect_lan_ip() -> Option { + if let Ok(s) = StdUdpSocket::bind("0.0.0.0:0") { + if s.connect("192.0.2.1:9").is_ok() { + if let Ok(SocketAddr::V4(a)) = s.local_addr() { + if is_lan_v4(*a.ip()) { + return Some(*a.ip()); + } + } + } + } + let all = lan_interfaces(); + all.iter() + .map(|(ip, _)| *ip) + .find(|ip| !(ip.octets()[0] == 172 && ip.octets()[1] == 17)) // не docker0 + .or_else(|| all.first().map(|(ip, _)| *ip)) +} + +// ───────────────────────── Probe ───────────────────────── +/// Рукопожатие с узлом. Отпечаток берём из РЕАЛЬНО предъявленного сертификата. +pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result { + let url = format!("https://{}:{}/p2p/info", ip, port); + let mut resp = state + .probe_client + .get(&url) + .header("x-node-name", pct_encode(&state.device_name)) + .header("x-node-port", state.p2p_port.to_string()) + .send() + .await + .map_err(|_| "узел недоступен".to_string())?; + + if !resp.status().is_success() { + return Err(format!("узел вернул код {}", resp.status())); + } + let fp = resp + .extensions() + .get::() + .and_then(|t| t.peer_certificate()) + .map(fingerprint) + .ok_or_else(|| "не удалось получить сертификат узла".to_string())?; + + if resp.content_length().map_or(false, |l| l as usize > MAX_PROBE_BODY) { + return Err("слишком большой ответ".into()); + } + let mut buf: Vec = Vec::new(); + while let Some(chunk) = resp.chunk().await.map_err(|_| "ошибка чтения ответа".to_string())? { + buf.extend_from_slice(&chunk); + if buf.len() > MAX_PROBE_BODY { + return Err("слишком большой ответ".into()); + } + } + let v: serde_json::Value = serde_json::from_slice(&buf).map_err(|_| "некорректный ответ".to_string())?; + Ok(ProbeInfo { + fp, + name: clean_label(v["name"].as_str().unwrap_or("Node"), 48), + }) +} + +pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result { + let info = probe(state, ip, port).await?; + if info.fp == state.my_fingerprint { + return Err("это ваше собственное устройство".into()); + } + state.upsert_peer(&info.name, ip, port, &info.fp); + Ok(info) +} + +// ───────────────────────── Ping ───────────────────────── +pub async fn ping_all_peers(state: &Arc) { + let Ok(_guard) = state.ping_lock.try_lock() else { + return; // опрос уже идёт + }; + let targets: Vec<(String, Vec, u16)> = state + .peers + .read() + .values() + .filter(|p| !p.ips.is_empty()) + .map(|p| (p.id.clone(), p.ips.clone(), p.port)) + .collect(); + + futures_util::stream::iter(targets) + .for_each_concurrent(8, |(id, ips, port)| async move { + ping_one(state, &id, ips, port).await; + }) + .await; +} + +async fn ping_one(state: &AppState, id: &str, ips: Vec, port: u16) { + for ip_s in ips { + let Ok(ip) = ip_s.parse::() else { continue }; + if let Ok(info) = probe(state, ip, port).await { + if info.fp == id { + state.upsert_peer(&info.name, ip, port, id); + return; + } + // по этому адресу теперь другое устройство — игнорируем + } + } + let mut peers = state.peers.write(); + if let Some(p) = peers.get_mut(id) { + if now_sec().saturating_sub(p.last_seen) > 15 { + p.online = false; + } + } +} + +// ───────────────────────── Сканер ───────────────────────── +pub async fn run_scan(state: Arc, ips: Vec, port: u16) { + let st = &state; + futures_util::stream::iter(ips) + .for_each_concurrent(48, |ip| async move { + let addr = SocketAddr::new(IpAddr::V4(ip), port); + let open = matches!( + tokio::time::timeout(Duration::from_millis(500), tokio::net::TcpStream::connect(addr)).await, + Ok(Ok(_)) + ); + let found = open && probe_and_register(st, IpAddr::V4(ip), port).await.is_ok(); + let mut s = st.scan_status.write(); + s.scanned += 1; + if found { + s.found += 1; + } + }) + .await; +} + +// ───────────────────────── UDP-обнаружение (без mDNS) ───────────────────────── +/// Beacon — лишь подсказка «я здесь». Ему НЕ доверяют: пир регистрируется только +/// после настоящего TLS-рукопожатия (probe), поэтому подделка UDP не отравляет список адресов. +#[derive(Serialize, Deserialize)] +struct Beacon { + v: u8, + port: u16, + fp: String, +} + +fn bind_discovery_socket() -> std::io::Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + socket.set_broadcast(true)?; + socket.set_nonblocking(true)?; + let addr: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DISCOVERY_PORT); + socket.bind(&addr.into())?; + UdpSocket::from_std(socket.into()) +} + +pub fn start_discovery(state: Arc) { + // отправка + let tx = state.clone(); + tokio::spawn(async move { + let sock = match UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => s, + Err(e) => { + tx.add_log(format!("Ошибка UDP-сокета отправки: {}", e)); + return; + } + }; + let _ = sock.set_broadcast(true); + let pkt = serde_json::to_vec(&Beacon { + v: 1, + port: tx.p2p_port, + fp: tx.my_fingerprint.clone(), + }) + .unwrap_or_default(); + + loop { + // интерфейсы пересчитываются каждый цикл — смена сети подхватится сама + let mut targets: Vec = lan_interfaces() + .into_iter() + .map(|(ip, bc)| { + bc.unwrap_or_else(|| { + let o = ip.octets(); + Ipv4Addr::new(o[0], o[1], o[2], 255) + }) + }) + .collect(); + targets.push(Ipv4Addr::BROADCAST); + targets.sort(); + targets.dedup(); + for t in targets { + let _ = sock + .send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT)) + .await; + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }); + + // приём + tokio::spawn(async move { + let socket = match bind_discovery_socket() { + Ok(s) => s, + Err(e) => { + state.add_log(format!( + "UDP-порт {} недоступен ({}). Автообнаружение отключено, используйте сканер.", + DISCOVERY_PORT, e + )); + return; + } + }; + let slots = Arc::new(Semaphore::new(8)); + let mut buf = [0u8; 512]; + loop { + let (len, remote) = match socket.recv_from(&mut buf).await { + Ok(v) => v, + Err(_) => { + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let Ok(b) = serde_json::from_slice::(&buf[..len]) else { continue }; + if b.v != 1 || b.port == 0 || !is_valid_fp(&b.fp) || b.fp == state.my_fingerprint { + continue; + } + // адрес — только реальный адрес источника пакета + let IpAddr::V4(ip4) = remote.ip() else { continue }; + if !is_lan_v4(ip4) { + continue; + } + let ip = IpAddr::V4(ip4); + if state.is_fresh(&b.fp, ip) || !state.probe_allowed(ip) { + continue; + } + let Ok(permit) = slots.clone().try_acquire_owned() else { continue }; + let st = state.clone(); + let port = b.port; + tokio::spawn(async move { + let _permit = permit; + let _ = probe_and_register(&st, ip, port).await; + }); + } + }); +} diff --git a/src/p2p.rs b/src/p2p.rs index d786767..b9affa4 100644 --- a/src/p2p.rs +++ b/src/p2p.rs @@ -1,195 +1,257 @@ -use crate::models::{ - now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem, - TransferProgress, MAX_RAM_TEXTS, MEDIA_DIR, -}; +use crate::{models::*, storage}; use axum::{ - body::Bytes, - extract::State, - http::{header, HeaderMap, StatusCode}, - Json, + body::Body, + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Extension, Json, Router, }; -use std::{path::PathBuf, sync::Arc, time::Duration}; -use tokio::io::AsyncWriteExt; +use futures_util::StreamExt; +use hyper_util::{ + rt::{TokioIo, TokioTimer}, + service::TowerToHyperService, +}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::{io::AsyncWriteExt, net::TcpListener, sync::Semaphore}; +use tokio_rustls::TlsAcceptor; -/// Публичный эндпоинт рукопожатия и взаимного обнаружения -pub async fn p2p_info( - State(state): State>, - headers: HeaderMap, -) -> Json { - let caller_fp = headers - .get("X-Node-Fp") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - let caller_name = headers - .get("X-Node-Name") - .and_then(|v| v.to_str().ok()) - .map(url_decode) - .unwrap_or_default(); - let caller_ip = headers - .get("X-Node-Ip") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - let caller_port: u16 = headers - .get("X-Node-Port") - .and_then(|v| v.to_str().ok()) - .and_then(|p| p.parse().ok()) - .unwrap_or(8443); - - if !caller_fp.is_empty() && !caller_ip.is_empty() { - state.upsert_peer(caller_name, caller_ip, caller_port, caller_fp); - } - - Json(serde_json::json!({ - "name": state.device_name, - "ip": state.my_ip, - "port": state.p2p_port, - "fingerprint": state.my_fingerprint, - "pin_code": state.my_fingerprint[..8].to_uppercase() - })) +/// Личность собеседника из TLS-рукопожатия (а не из заголовков!). +#[derive(Clone)] +pub struct PeerCtx { + pub fp: String, + pub addr: SocketAddr, } -/// Прием текста -> сохраняется СТРОГО В ОЗУ -pub async fn p2p_receive_text( - State(state): State>, - Json(payload): Json, -) -> StatusCode { - state.add_log(format!("Получен текст в ОЗУ от '{}'", payload.sender)); +pub fn router(state: Arc) -> Router { + Router::new() + .route("/p2p/info", get(p2p_info)) + .route( + "/p2p/text", + post(p2p_receive_text).layer(DefaultBodyLimit::max(512 * 1024)), + ) + // сырой Body не ограничивается DefaultBodyLimit: лимиты считаем сами при потоковом чтении + .route("/p2p/media", post(p2p_receive_media)) + .with_state(state) +} - let mut ram_texts = state.ram_texts.write().unwrap(); - if ram_texts.len() >= MAX_RAM_TEXTS { - ram_texts.pop_back(); +/// Accept-loop: TLS 1.3 + обязательный клиентский сертификат; отпечаток кладётся в Extension. +pub async fn serve(listener: TcpListener, acceptor: TlsAcceptor, app: Router) { + let conn_slots = Arc::new(Semaphore::new(128)); + loop { + let (tcp, addr) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + eprintln!("[ERR] accept: {}", e); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let Ok(permit) = conn_slots.clone().try_acquire_owned() else { + continue; // перегрузка: молча сбрасываем соединение + }; + let acceptor = acceptor.clone(); + let app = app.clone(); + + tokio::spawn(async move { + let _permit = permit; + let _ = tcp.set_nodelay(true); + let tls = match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(tcp)).await { + Ok(Ok(t)) => t, + _ => return, + }; + let fp = match tls.get_ref().1.peer_certificates().and_then(|c| c.first()) { + Some(c) => fingerprint(c.as_ref()), + None => return, + }; + let svc = app.layer(Extension(PeerCtx { fp, addr })); + + let mut http = hyper::server::conn::http1::Builder::new(); + http.timer(TokioTimer::new()); + http.header_read_timeout(Duration::from_secs(10)); + let _ = http + .serve_connection(TokioIo::new(tls), TowerToHyperService::new(svc)) + .await; + }); } - ram_texts.push_front(TextItem { - sender: payload.sender, +} + +// ───────────────────────── Handlers ───────────────────────── +/// Рукопожатие. Любой владелец сертификата регистрируется как НЕЗАВЕРЕННЫЙ пир; +/// IP — из сокета, отпечаток — из TLS. Заголовки несут лишь отображаемое имя и порт. +async fn p2p_info( + State(state): State>, + Extension(ctx): Extension, + headers: HeaderMap, +) -> Json { + if let Some(port) = hdr(&headers, "x-node-port").and_then(|v| v.parse::().ok()) { + let name = hdr(&headers, "x-node-name").map(pct_decode).unwrap_or_default(); + state.upsert_peer(&name, ctx.addr.ip(), port, &ctx.fp); + } + Json(serde_json::json!({ "name": state.device_name, "port": state.p2p_port })) +} + +async fn p2p_receive_text( + State(state): State>, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result { + let sender = state + .trusted_name(&ctx.fp) + .ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?; + if payload.text.trim().is_empty() { + return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст")); + } + if payload.text.len() > MAX_TEXT_BYTES { + return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст слишком большой")); + } + state.add_log(format!("Получен текст в ОЗУ от '{}'", sender)); + let mut q = state.ram_texts.write(); + if q.len() >= MAX_RAM_TEXTS { + q.pop_back(); + } + q.push_front(TextItem { + sender, text: payload.text, timestamp: now_sec(), }); - StatusCode::OK + Ok(StatusCode::OK) } -/// Прием медиаконтента (поддерживает почанковую передачу с прогресс-баром и записью на диск) -pub async fn p2p_receive_media( - State(state): State>, - headers: HeaderMap, - body: Bytes, -) -> Result { - let sender = headers - .get("X-Sender") - .and_then(|v| v.to_str().ok()) - .map(url_decode) - .unwrap_or_else(|| "Unknown".to_string()); - - let raw_filename = headers - .get("X-Filename") - .and_then(|v| v.to_str().ok()) - .unwrap_or("file.bin"); - - let mime = headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - let transfer_id = headers - .get("X-Transfer-Id") - .and_then(|v| v.to_str().ok()) - .map(sanitize_filename) - .unwrap_or_else(|| format!("rx_{}", now_sec())); - - let offset: usize = headers - .get("X-Offset") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - - let total_size: usize = headers - .get("X-Total-Size") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()) - .unwrap_or_else(|| body.len()); - - tokio::fs::create_dir_all(MEDIA_DIR) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let safe_name = sanitize_filename(raw_filename); - let temp_path = PathBuf::from(MEDIA_DIR).join(format!(".part_{}_{}", transfer_id, safe_name)); - - if offset == 0 { - tokio::fs::write(&temp_path, &body) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - } else { - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&temp_path) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - file.write_all(&body) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; +struct TempGuard { + path: PathBuf, + armed: bool, +} +impl Drop for TempGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } } +} - let received_bytes = offset + body.len(); - let is_done = received_bytes >= total_size; - let percent = if total_size == 0 { - 100 - } else { - ((received_bytes as f64 / total_size as f64) * 100.0).min(100.0) as u8 - }; +fn fail_rx(state: &Arc, id: &str, e: AppError) -> AppError { + state.fail_transfer(id, e.1.clone()); + remove_transfer_later(state, id.to_string(), 10); + e +} - let rx_id = format!("in_{}", transfer_id); - state.upsert_transfer(TransferProgress { - id: rx_id.clone(), +/// Потоковый приём файла: память O(1), лимиты по факту прочитанных байт, таймаут простоя. +async fn p2p_receive_media( + State(state): State>, + Extension(ctx): Extension, + headers: HeaderMap, + body: Body, +) -> Result { + let sender = state + .trusted_name(&ctx.fp) + .ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?; + + let total: u64 = hdr(&headers, "x-total-size") + .and_then(|v| v.parse().ok()) + .ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан размер файла"))?; + if total == 0 || total > MAX_FILE_SIZE { + return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Недопустимый размер файла")); + } + let used: u64 = state.disk_media.read().iter().map(|m| m.size).sum(); + if used.saturating_add(total) > MAX_MEDIA_QUOTA { + return Err(AppError::new(StatusCode::INSUFFICIENT_STORAGE, "Превышена квота хранилища")); + } + let _slot = state + .incoming_slots + .clone() + .try_acquire_owned() + .map_err(|_| AppError::new(StatusCode::TOO_MANY_REQUESTS, "Слишком много входящих передач"))?; + + let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin"))); + + tokio::fs::create_dir_all(&state.media_dir) + .await + .map_err(|e| AppError::internal("mkdir", e))?; + let tmp = state.media_dir.join(format!(".part_{}", random_hex(8))); + let mut guard = TempGuard { path: tmp.clone(), armed: true }; + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .await + .map_err(|e| AppError::internal("open tmp", e))?; + + let id = format!("in_{}", random_hex(6)); + state.add_transfer(TransferProgress { + id: id.clone(), filename: safe_name.clone(), peer_name: sender.clone(), - direction: "in".to_string(), - transferred_bytes: received_bytes, - total_bytes: total_size, - percent, - done: is_done, + direction: Direction::In, + transferred_bytes: 0, + total_bytes: total, + percent: 0, + done: false, error: None, updated_at: now_sec(), }); - if is_done { - let final_filename = format!("{}_{}", now_sec(), safe_name); - let final_path = PathBuf::from(MEDIA_DIR).join(&final_filename); - - tokio::fs::rename(&temp_path, &final_path) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let abs_path = std::fs::canonicalize(&final_path) - .unwrap_or_else(|_| final_path.clone()) - .display() - .to_string(); - - state.add_log(format!( - "Принят медиафайл '{}' ({} байт) от '{}'", - final_filename, total_size, sender - )); - - let mut media_list = state.disk_media.write().unwrap(); - media_list.push_front(MediaItem { - sender, - filename: final_filename, - saved_path: abs_path, - mime, - size: total_size, - timestamp: now_sec(), - }); - drop(media_list); - - let st_cleanup = state.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(4)).await; - st_cleanup.remove_transfer(&rx_id); - }); + // ── чтение потока ── + let mut stream = std::pin::pin!(body.into_data_stream()); + let mut got: u64 = 0; + let mut last = Instant::now(); + loop { + let next = match tokio::time::timeout(Duration::from_secs(30), stream.next()).await { + Ok(n) => n, + Err(_) => return Err(fail_rx(&state, &id, AppError::new(StatusCode::REQUEST_TIMEOUT, "Таймаут передачи"))), + }; + let Some(chunk) = next else { break }; + let chunk = match chunk { + Ok(c) => c, + Err(_) => return Err(fail_rx(&state, &id, AppError::new(StatusCode::BAD_REQUEST, "Обрыв передачи"))), + }; + got += chunk.len() as u64; + if got > total { + return Err(fail_rx(&state, &id, AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Данных больше заявленного"))); + } + if let Err(e) = file.write_all(&chunk).await { + return Err(fail_rx(&state, &id, AppError::internal("write", e))); + } + if last.elapsed() >= Duration::from_millis(100) { + state.update_transfer_bytes(&id, got); + last = Instant::now(); + } } + if got != total { + return Err(fail_rx(&state, &id, AppError::new(StatusCode::BAD_REQUEST, "Размер не совпал с заявленным"))); + } + if let Err(e) = file.sync_all().await { + return Err(fail_rx(&state, &id, AppError::internal("sync", e))); + } + drop(file); + let final_name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name); + let final_path = state.media_dir.join(&final_name); + if let Err(e) = tokio::fs::rename(&tmp, &final_path).await { + return Err(fail_rx(&state, &id, AppError::internal("rename", e))); + } + guard.armed = false; + + { + let mut list = state.disk_media.write(); + list.push_front(MediaItem { + sender: sender.clone(), + filename: final_name.clone(), + saved_path: final_path.display().to_string(), + mime: storage::mime_from_name(&safe_name).into(), + size: total, + timestamp: now_sec(), + previewable: storage::is_previewable(&safe_name), + risky: storage::is_risky(&safe_name), + }); + list.truncate(MAX_MEDIA_LIST); + } + state.finish_transfer(&id); + remove_transfer_later(&state, id, 4); + state.add_log(format!("Принят файл '{}' ({} байт) от '{}'", final_name, total, sender)); Ok(StatusCode::OK) } diff --git a/src/static/app.js b/src/static/app.js new file mode 100644 index 0000000..a07cdf1 --- /dev/null +++ b/src/static/app.js @@ -0,0 +1,327 @@ +'use strict'; + +const $ = (id) => document.getElementById(id); +let statusTimer = null; +let lastData = null; +let knownPeersKey = ''; +let rangeInitialized = false; +let pendingTrustId = null; + +function esc(v) { + return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s])); +} + +function showStatus(msg, isError = false) { + const el = $('status'); + clearTimeout(statusTimer); + el.style.display = 'block'; + el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)'; + el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`; + el.textContent = msg; + statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500); +} + +function formatBytes(b) { + if (b < 1024) return `${b} Б`; + if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`; + if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`; + return `${(b / 1024 ** 3).toFixed(2)} ГБ`; +} + +// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки) +function setHtml(el, html) { + if (el._h !== html) { el._h = html; el.innerHTML = html; } +} + +async function api(path, opts = {}) { + const res = await fetch(path, { credentials: 'same-origin', ...opts }); + if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`); + return res; +} + +function postJson(path, body) { + return api(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// ───────────── рендер ───────────── +function renderHeader(d) { + $('my-name').textContent = `Устройство: ${d.device_name}`; + $('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`; + $('my-pin').textContent = d.my_pin; + $('media-dir').textContent = d.media_dir; + if (!rangeInitialized && d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) { + const p = d.my_ip.split('.'); + $('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`; + rangeInitialized = true; + } +} + +function renderScan(scan) { + const box = $('scan-progress-box'); + if (scan && (scan.active || scan.total > 0)) { + box.style.display = 'block'; + const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0; + $('scan-label').textContent = scan.active + ? `Сканирование ${scan.range}… (найдено: ${scan.found})` + : `Сканирование ${scan.range} завершено (найдено: ${scan.found})`; + $('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`; + $('scan-bar-fill').style.width = `${pct}%`; + $('btn-scan-range').disabled = scan.active; + $('btn-scan-all').disabled = scan.active; + } +} + +function renderTransfers(list) { + setHtml($('transfers-list'), (list || []).map((t) => { + const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от'; + const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`; + const note = t.error + ? `${esc(t.error)}` + : (t.done ? 'Готово' : 'Передача по TLS…'); + return ` +
+
+ ${dir} ${esc(t.peer_name)}: ${esc(t.filename)} + ${esc(status)} +
+
+
+ ${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)} + ${note} +
+
`; + }).join('')); +} + +function renderPeers(peers) { + const box = $('peers-list'); + if (!peers.length) { + setHtml(box, '
Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.
'); + } else { + setHtml(box, peers.map((p) => ` +
+
+
+ ${esc(p.name)} + (${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)}) +
+
+ Код сверки: ${esc(p.pin_code)}
+ Статус: ${p.verified ? 'Заверен' : 'Не заверен'} +
+
+ +
`).join('')); + } + + const key = peers.map((p) => `${p.id}:${p.verified}:${p.ips[0] || ''}:${p.name}`).join('|'); + if (key !== knownPeersKey) { + knownPeersKey = key; + const sel = $('target-peer'); + const prev = sel.value; + sel.innerHTML = '' + peers.map((p) => + ``).join(''); + if (prev) sel.value = prev; + } +} + +function renderTexts(texts) { + const box = $('ram-texts'); + if (!texts.length) return setHtml(box, '
Буфер ОЗУ пуст
'); + setHtml(box, texts.map((t, i) => ` +
+
+ От: ${esc(t.sender)} + +
+
${esc(t.text)}
+
`).join('')); +} + +function renderMedia(media) { + const box = $('disk-media'); + if (!media.length) return setHtml(box, '
Нет сохранённых файлов
'); + setHtml(box, media.map((m) => { + const url = `/ui/files/${encodeURIComponent(m.filename)}`; + return ` +
+
От: ${esc(m.sender)}${formatBytes(m.size)}
+ + ${m.risky ? '
⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.
' : ''} + ${m.previewable ? `` : ''} +
`; + }).join('')); +} + +async function fetchState() { + const d = await (await api('/ui/state')).json(); + lastData = d; + renderHeader(d); + renderScan(d.scan); + renderTransfers(d.transfers); + renderPeers(d.peers); + renderTexts(d.texts); + renderMedia(d.media); +} + +// ───────────── действия ───────────── +async function guarded(fn) { + try { await fn(); } catch (e) { showStatus(e.message || String(e), true); } +} + +function scanIpRange(full) { + return guarded(async () => { + const range = full ? 'all' : $('scan-range-input').value.trim(); + if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true); + await postJson('/ui/scan-range', { range }); + showStatus('Сканирование запущено…'); + fetchState().catch(() => {}); + }); +} + +function addManualPeer() { + return guarded(async () => { + const raw = $('manual-ip-input').value.trim(); + if (!raw) return showStatus('Введите IP-адрес устройства', true); + let ip = raw, port = null; + const i = raw.lastIndexOf(':'); + if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; } + showStatus(`Связь с узлом ${ip}…`); + await postJson('/ui/manual-peer', port ? { ip, port } : { ip }); + $('manual-ip-input').value = ''; + showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`); + fetchState().catch(() => {}); + }); +} + +function pingPeers() { + return guarded(async () => { + await api('/ui/ping', { method: 'POST' }); + showStatus('Запущен опрос известных узлов'); + setTimeout(() => fetchState().catch(() => {}), 800); + }); +} + +function setTrust(id, trust) { + return guarded(async () => { + await postJson('/ui/trust', { peer_id: id, trust }); + showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано'); + fetchState().catch(() => {}); + }); +} + +function sendText() { + return guarded(async () => { + const peer_id = $('target-peer').value; + const input = $('text-input'); + const text = input.value.trim(); + if (!peer_id) return showStatus('Выберите устройство-получатель!', true); + if (!text) return showStatus('Введите текст для отправки!', true); + if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true); + await postJson('/ui/send-text', { peer_id, text }); + input.value = ''; + showStatus('Текст доставлен в ОЗУ получателя'); + }); +} + +async function sendMedia() { + const peer_id = $('target-peer').value; + const fileInput = $('media-input'); + const btn = $('send-media-btn'); + if (!peer_id) return showStatus('Выберите устройство-получатель!', true); + if (!fileInput.files.length) return showStatus('Выберите файл!', true); + const file = fileInput.files[0]; + btn.disabled = true; + try { + await api('/ui/send-media', { + method: 'POST', + headers: { + 'X-Target-Id': peer_id, + 'X-Filename': encodeURIComponent(file.name), + 'Content-Type': 'application/octet-stream', + }, + body: file, + }); + fileInput.value = ''; + showStatus(`Файл «${file.name}» передан`); + } catch (e) { + showStatus(e.message || String(e), true); + } finally { + btn.disabled = false; + fetchState().catch(() => {}); + } +} + +function clearRam() { + return guarded(async () => { + await api('/ui/clear-ram', { method: 'POST' }); + showStatus('Буфер текстов в ОЗУ очищен'); + fetchState().catch(() => {}); + }); +} + +// ───────────── события (без inline-обработчиков: CSP script-src 'self') ───────────── +$('btn-ping').addEventListener('click', pingPeers); +$('btn-manual').addEventListener('click', addManualPeer); +$('btn-scan-range').addEventListener('click', () => scanIpRange(false)); +$('btn-scan-all').addEventListener('click', () => scanIpRange(true)); +$('btn-send-text').addEventListener('click', sendText); +$('send-media-btn').addEventListener('click', sendMedia); +$('btn-clear-ram').addEventListener('click', clearRam); + +$('text-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); } +}); + +$('peers-list').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-action="trust"]'); + if (!btn || !lastData) return; + const id = btn.dataset.id; + if (btn.dataset.trust === '0') return setTrust(id, false); + const peer = lastData.peers.find((p) => p.id === id); + if (!peer) return; + pendingTrustId = id; + $('td-name').textContent = peer.name; + $('td-code').textContent = peer.pin_code; + $('trust-dialog').showModal(); +}); + +$('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); }); +$('td-ok').addEventListener('click', () => { + const id = pendingTrustId; + pendingTrustId = null; + $('trust-dialog').close(); + if (id) setTrust(id, true); +}); + +$('ram-texts').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-action="copy"]'); + if (!btn || !lastData) return; + const item = lastData.texts[Number(btn.dataset.idx)]; + if (!item) return; + navigator.clipboard.writeText(item.text).then( + () => showStatus('Текст скопирован в буфер обмена'), + () => showStatus('Не удалось скопировать', true), + ); +}); + +// ───────────── опрос состояния: следующий запрос только после завершения предыдущего ───────────── +async function poll() { + try { + if (!document.hidden) await fetchState(); + $('offline').style.display = 'none'; + } catch (_) { + $('offline').style.display = 'block'; + } + setTimeout(poll, document.hidden ? 2500 : 700); +} +poll(); diff --git a/src/static/index.html b/src/static/index.html index 9a829f9..9dd1c8b 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -3,7 +3,7 @@ -LAN Share — Постоянный PIN | Текст в ОЗУ | Медиа на Диск +LAN Share
-
Загрузка...
-
+
Загрузка...
+
- Постоянный PIN устройства: + ID устройства: --------
+
Нет связи с приложением. Проверьте, что оно запущено.
-

1. Устройства в сети - +

- -
- - +
+ +
-
-
Сканер диапазона IP-адресов:
-
- - - +
Сканер диапазона IP-адресов (только локальные сети):
+
+ + +
- -
-

2. Отправка данных (по нажатию)

- +

2. Отправка данных

+ - - + + -
+
- + - -
+
-

Входящий текст ТОЛЬКО В ОЗУ - +

-
+
-
-

Входящие медиафайлы НА ДИСКЕ (./received_media)

-
+

Входящие файлы НА ДИСКЕ

+
+
- + diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..5e2d2bb --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,162 @@ +use crate::models::MediaItem; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, + time::UNIX_EPOCH, +}; + +#[derive(Serialize, Deserialize)] +pub struct TrustedEntry { + pub fp: String, + pub name: String, + #[serde(default)] + pub ips: Vec, + #[serde(default)] + pub port: u16, +} + +pub fn default_data_dir(p2p_port: u16) -> PathBuf { + let base: Option = if cfg!(windows) { + std::env::var_os("LOCALAPPDATA").map(PathBuf::from) + } else if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support")) + } else { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + }; + let name = if p2p_port == 8443 { + "ramshare".to_string() + } else { + format!("ramshare-{}", p2p_port) + }; + base.unwrap_or_else(|| PathBuf::from(".")).join(name) +} + +/// Атомарная запись: tmp + fsync + rename. Для секретов на Unix права 0600. +pub fn write_atomic(path: &Path, data: &[u8], private: bool) -> io::Result<()> { + let mut tmp_name = path.file_name().unwrap_or_default().to_os_string(); + tmp_name.push(".tmp"); + let tmp = path.with_file_name(tmp_name); + + let mut opts = fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + if private { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + } + let _ = private; + + let res = (|| { + let mut f = opts.open(&tmp)?; + f.write_all(data)?; + f.sync_all()?; + fs::rename(&tmp, path) + })(); + if res.is_err() { + let _ = fs::remove_file(&tmp); + } + res +} + +pub fn load_trusted(dir: &Path) -> Vec { + let path = dir.join("trusted.json"); + match fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| { + eprintln!("[WARN] trusted.json повреждён ({}), список доверенных пуст", e); + Vec::new() + }), + Err(_) => Vec::new(), + } +} + +pub fn save_trusted(dir: &Path, entries: &[TrustedEntry]) -> io::Result<()> { + let json = serde_json::to_vec_pretty(entries).map_err(io::Error::other)?; + write_atomic(&dir.join("trusted.json"), &json, false) +} + +fn ext_of(name: &str) -> String { + Path::new(name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default() +} + +pub fn mime_from_name(name: &str) -> &'static str { + match ext_of(name).as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + "txt" | "md" | "log" => "text/plain", + "mp4" => "video/mp4", + "mp3" => "audio/mpeg", + "zip" => "application/zip", + _ => "application/octet-stream", + } +} + +/// Только растровые изображения показываем inline. +pub fn is_previewable(name: &str) -> bool { + matches!(ext_of(name).as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp") +} + +pub fn is_risky(name: &str) -> bool { + matches!( + ext_of(name).as_str(), + "exe" | "bat" | "cmd" | "com" | "scr" | "msi" | "lnk" | "js" | "jse" | "vbs" | "vbe" | "wsf" | "ps1" + | "jar" | "apk" | "sh" | "html" | "htm" | "svg" | "hta" | "dll" | "reg" | "app" | "dmg" | "pif" | "cpl" + ) +} + +pub fn cleanup_partials(dir: &Path) { + if let Ok(rd) = fs::read_dir(dir) { + for e in rd.flatten() { + if e.file_name().to_string_lossy().starts_with(".part_") { + let _ = fs::remove_file(e.path()); + } + } + } +} + +/// Восстанавливает список принятых файлов после перезапуска. +pub fn scan_media_dir(dir: &Path) -> Vec { + let mut items = Vec::new(); + if let Ok(rd) = fs::read_dir(dir) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + continue; + } + let Ok(meta) = e.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let ts = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_secs()); + items.push(MediaItem { + sender: "—".into(), + mime: mime_from_name(&name).into(), + previewable: is_previewable(&name), + risky: is_risky(&name), + saved_path: e.path().display().to_string(), + size: meta.len(), + timestamp: ts, + filename: name, + }); + } + } + items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + items.truncate(crate::models::MAX_MEDIA_LIST); + items +} diff --git a/src/tls.rs b/src/tls.rs index 40c1c7f..0699923 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,239 +1,285 @@ -use crate::models::{PeerDevice, SavedIdentity}; +use crate::{ + models::{fingerprint, PeerDevice}, + storage, +}; +use parking_lot::RwLock; use rcgen::{generate_simple_self_signed, CertifiedKey}; use rustls::{ client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider}, - pki_types::{CertificateDer, ServerName, UnixTime}, - ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme, + crypto::{verify_tls13_signature, CryptoProvider}, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime}, + server::danger::{ClientCertVerified, ClientCertVerifier}, + ClientConfig, DigitallySignedStruct, DistinguishedName, Error as TlsError, ServerConfig, SignatureScheme, }; -use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, - path::Path, - sync::{Arc, RwLock}, + path::{Path, PathBuf}, + sync::{Arc, OnceLock}, time::Duration, }; -#[derive(Debug)] -pub struct PinnedCertVerifier { - peers: Arc>>, - provider: Arc, +type Peers = Arc>>; + +fn provider() -> Arc { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(|| Arc::new(rustls::crypto::ring::default_provider())) + .clone() } -impl PinnedCertVerifier { - pub fn new(peers: Arc>>) -> Self { - Self { - peers, - provider: Arc::new(rustls::crypto::ring::default_provider()), +// ───────────────────────── Идентичность ───────────────────────── +pub struct Identity { + pub cert_der: Vec, + pub key_der: Vec, + pub fingerprint: String, +} + +#[derive(Serialize, Deserialize)] +struct SavedIdentity { + cert_hex: String, + key_hex: String, + fingerprint: String, +} + +fn read_identity(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| format!("чтение {}: {}", path.display(), e))?; + let saved: SavedIdentity = + serde_json::from_str(&content).map_err(|e| format!("файл {} повреждён: {}", path.display(), e))?; + let cert_der = hex::decode(&saved.cert_hex).map_err(|_| format!("{}: неверный cert_hex", path.display()))?; + let key_der = hex::decode(&saved.key_hex).map_err(|_| format!("{}: неверный key_hex", path.display()))?; + let fp = fingerprint(&cert_der); + if fp != saved.fingerprint { + return Err(format!("{}: отпечаток не совпадает с сертификатом", path.display())); + } + Ok(Identity { cert_der, key_der, fingerprint: fp }) +} + +fn save_identity(path: &Path, id: &Identity) -> Result<(), String> { + let json = serde_json::to_vec_pretty(&SavedIdentity { + cert_hex: hex::encode(&id.cert_der), + key_hex: hex::encode(&id.key_der), + fingerprint: id.fingerprint.clone(), + }) + .map_err(|e| e.to_string())?; + storage::write_atomic(path, &json, true).map_err(|e| format!("запись {}: {}", path.display(), e)) +} + +/// Битый файл идентичности — ошибка запуска, а НЕ тихая регенерация. +pub fn load_or_create_identity(data_dir: &Path, legacy_port: u16) -> Result { + let path = data_dir.join("identity.json"); + if path.exists() { + return read_identity(&path); + } + // миграция со старой версии (.identity_.json в текущей папке) + let legacy = PathBuf::from(format!(".identity_{}.json", legacy_port)); + if legacy.exists() { + if let Ok(id) = read_identity(&legacy) { + save_identity(&path, &id)?; + return Ok(id); } } + let CertifiedKey { cert, key_pair } = + generate_simple_self_signed(vec!["ramshare.local".to_string()]).map_err(|e| format!("генерация сертификата: {e}"))?; + let cert_der = cert.der().to_vec(); + let id = Identity { + fingerprint: fingerprint(&cert_der), + cert_der, + key_der: key_pair.serialize_der(), + }; + save_identity(&path, &id)?; + Ok(id) +} - fn cert_fingerprint(cert: &CertificateDer<'_>) -> String { - let mut hasher = Sha256::new(); - hasher.update(cert.as_ref()); - hex::encode(hasher.finalize()) +fn key_of(der: &[u8]) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(der.to_vec())) +} + +// ───────────────────────── Сервер: mTLS ───────────────────────── +/// Принимает любой самоподписанный сертификат, но ПРОВЕРЯЕТ подпись (владение ключом). +/// Решение о доверии принимается в handler'е по отпечатку из рукопожатия. +#[derive(Debug)] +struct AnyClientCert; + +impl ClientCertVerifier for AnyClientCert { + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + fn client_auth_mandatory(&self) -> bool { + true + } + fn verify_client_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _now: UnixTime, + ) -> Result { + Ok(ClientCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _m: &[u8], + _c: &CertificateDer<'_>, + _d: &DigitallySignedStruct, + ) -> Result { + Err(TlsError::General("tls12-disabled".into())) + } + fn verify_tls13_signature( + &self, + m: &[u8], + c: &CertificateDer<'_>, + d: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms) + } + fn supported_verify_schemes(&self) -> Vec { + provider().signature_verification_algorithms.supported_schemes() } } -impl ServerCertVerifier for PinnedCertVerifier { +pub fn server_config(id: &Identity) -> Result, String> { + let mut cfg = ServerConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| e.to_string())? + .with_client_cert_verifier(Arc::new(AnyClientCert)) + .with_single_cert(vec![CertificateDer::from(id.cert_der.clone())], key_of(&id.key_der)) + .map_err(|e| format!("сертификат/ключ не подходят друг другу: {e}"))?; + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(Arc::new(cfg)) +} + +// ───────────────────────── Клиенты ───────────────────────── +/// Пиннинг на КОНКРЕТНЫЙ отпечаток + пользователь должен был заверить пира. +#[derive(Debug)] +struct PinVerifier { + expected: String, + peers: Peers, +} + +impl ServerCertVerifier for PinVerifier { fn verify_server_cert( &self, end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _server_name: &ServerName<'_>, - _ocsp_response: &[u8], + _ocsp: &[u8], _now: UnixTime, ) -> Result { - let actual_fp = Self::cert_fingerprint(end_entity); - let pin = actual_fp.get(..8).unwrap_or("UNKNOWN").to_uppercase(); - - let peers_guard = self.peers.read().unwrap(); - match peers_guard.get(&actual_fp) { - Some(peer) if peer.verified => Ok(ServerCertVerified::assertion()), - Some(peer) => Err(TlsError::General(format!( - "Устройство '{}' [PIN: {}] не заверено! Нажмите «Заверить» в списке устройств.", - peer.name, pin - ))), - None => Err(TlsError::General(format!( - "Неизвестный сертификат устройства [PIN: {}]. Дождитесь обнаружения или добавьте по IP.", - pin - ))), + let fp = fingerprint(end_entity.as_ref()); + if fp != self.expected { + return Err(TlsError::General("peer-mismatch".into())); + } + let trusted = self.peers.read().get(&fp).map_or(false, |p| p.verified); + if trusted { + Ok(ServerCertVerified::assertion()) + } else { + Err(TlsError::General("peer-not-trusted".into())) } } - fn verify_tls12_signature( &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, + _m: &[u8], + _c: &CertificateDer<'_>, + _d: &DigitallySignedStruct, ) -> Result { - verify_tls12_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) + Err(TlsError::General("tls12-disabled".into())) } - fn verify_tls13_signature( &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, + m: &[u8], + c: &CertificateDer<'_>, + d: &DigitallySignedStruct, ) -> Result { - verify_tls13_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) + verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms) } - fn supported_verify_schemes(&self) -> Vec { - self.provider - .signature_verification_algorithms - .supported_schemes() + provider().signature_verification_algorithms.supported_schemes() } } +/// Принимает любой сертификат сервера; отпечаток берётся из реального рукопожатия (TlsInfo), +/// а НЕ из JSON-ответа. Используется только для /p2p/info. #[derive(Debug)] -pub struct AnyCertVerifier { - provider: Arc, -} +struct AnyServerCert; -impl AnyCertVerifier { - pub fn new() -> Self { - Self { - provider: Arc::new(rustls::crypto::ring::default_provider()), - } - } -} - -impl ServerCertVerifier for AnyCertVerifier { +impl ServerCertVerifier for AnyServerCert { fn verify_server_cert( &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, + _e: &CertificateDer<'_>, + _i: &[CertificateDer<'_>], + _s: &ServerName<'_>, + _o: &[u8], + _n: UnixTime, ) -> Result { Ok(ServerCertVerified::assertion()) } - fn verify_tls12_signature( &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, + _m: &[u8], + _c: &CertificateDer<'_>, + _d: &DigitallySignedStruct, ) -> Result { - verify_tls12_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) + Err(TlsError::General("tls12-disabled".into())) } - fn verify_tls13_signature( &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &DigitallySignedStruct, + m: &[u8], + c: &CertificateDer<'_>, + d: &DigitallySignedStruct, ) -> Result { - verify_tls13_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) + verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms) } - fn supported_verify_schemes(&self) -> Vec { - self.provider - .signature_verification_algorithms - .supported_schemes() + provider().signature_verification_algorithms.supported_schemes() } } -/// Загружает постоянный сертификат устройства или создает его один раз при первом запуске -pub fn load_or_create_identity(ip: &str, port: u16) -> (Vec, Vec, String) { - let identity_file = format!(".identity_{}.json", port); - let path = Path::new(&identity_file); - - if let Ok(content) = std::fs::read_to_string(path) { - if let Ok(saved) = serde_json::from_str::(&content) { - if let (Ok(cert_der), Ok(key_der)) = - (hex::decode(&saved.cert_hex), hex::decode(&saved.key_hex)) - { - let mut hasher = Sha256::new(); - hasher.update(&cert_der); - let actual_fp = hex::encode(hasher.finalize()); - if actual_fp == saved.fingerprint { - return (cert_der, key_der, actual_fp); - } - } - } - } - - let san = vec!["localhost".to_string(), ip.to_string()]; - let CertifiedKey { cert, key_pair } = - generate_simple_self_signed(san).expect("Ошибка генерации сертификата"); - - let cert_der = cert.der().to_vec(); - let key_der = key_pair.serialize_der(); - - let mut hasher = Sha256::new(); - hasher.update(&cert_der); - let fingerprint = hex::encode(hasher.finalize()); - - let to_save = SavedIdentity { - cert_hex: hex::encode(&cert_der), - key_hex: hex::encode(&key_der), - fingerprint: fingerprint.clone(), - }; - - if let Ok(json_str) = serde_json::to_string_pretty(&to_save) { - let _ = std::fs::write(path, json_str); - } - - (cert_der, key_der, fingerprint) +fn client_config( + verifier: Arc, + cert: &[u8], + key: &[u8], +) -> Result { + let mut cfg = ClientConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| e.to_string())? + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_client_auth_cert(vec![CertificateDer::from(cert.to_vec())], key_of(key)) + .map_err(|e| e.to_string())?; + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(cfg) } -pub fn build_pinned_http_client( - peers: Arc>>, -) -> reqwest::Client { - let verifier = Arc::new(PinnedCertVerifier::new(peers)); - let rustls_client_config = ClientConfig::builder_with_provider(Arc::new( - rustls::crypto::ring::default_provider(), - )) - .with_safe_default_protocol_versions() - .unwrap() - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_no_client_auth(); - +fn base_builder(cfg: ClientConfig) -> reqwest::ClientBuilder { reqwest::Client::builder() - .use_preconfigured_tls(rustls_client_config) - .timeout(Duration::from_secs(30)) - .build() - .expect("Ошибка создания HTTPS-клиента") + .use_preconfigured_tls(cfg) + .no_proxy() // не читать HTTP(S)_PROXY из окружения + .redirect(reqwest::redirect::Policy::none()) // нет SSRF через 302 + .pool_max_idle_per_host(0) // отзыв доверия действует сразу + .connect_timeout(Duration::from_secs(5)) } -pub fn build_probe_http_client() -> reqwest::Client { - let verifier = Arc::new(AnyCertVerifier::new()); - let rustls_client_config = ClientConfig::builder_with_provider(Arc::new( - rustls::crypto::ring::default_provider(), - )) - .with_safe_default_protocol_versions() - .unwrap() - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_no_client_auth(); +/// Клиент на одну передачу к конкретному пиру. +/// streaming=true: без общего таймаута (большие файлы); простой контролирует приёмник. +pub fn pinned_client( + expected_fp: String, + peers: Peers, + cert: &[u8], + key: &[u8], + streaming: bool, +) -> Result { + let cfg = client_config(Arc::new(PinVerifier { expected: expected_fp, peers }), cert, key)?; + let mut b = base_builder(cfg); + if !streaming { + b = b.timeout(Duration::from_secs(15)); + } + b.build().map_err(|e| e.to_string()) +} - reqwest::Client::builder() - .use_preconfigured_tls(rustls_client_config) +pub fn probe_client(cert: &[u8], key: &[u8]) -> Result { + let cfg = client_config(Arc::new(AnyServerCert), cert, key)?; + base_builder(cfg) + .tls_info(true) .timeout(Duration::from_secs(4)) .build() - .expect("Ошибка создания probe-клиента") + .map_err(|e| e.to_string()) } diff --git a/src/ui.rs b/src/ui.rs index 881883f..1685f9f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,433 +1,467 @@ -use crate::models::{ - now_sec, parse_ip_range, sanitize_filename, url_decode, AppState, IncomingTextPayload, - MediaItem, PeerDevice, ScanProgress, TextItem, TransferProgress, UiManualPeerRequest, - UiScanRangeRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR, -}; +use crate::{models::*, net, storage}; use axum::{ - body::Bytes, - extract::{Path, State}, - http::{header, HeaderMap, StatusCode}, - response::{Html, IntoResponse}, + body::{Body, Bytes}, + extract::{Path, Request, State}, + http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{Html, IntoResponse, Response}, Json, }; -use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; -use tokio::sync::Semaphore; +use futures_util::StreamExt; +use std::{ + io, + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::io::ReaderStream; const INDEX_HTML: &str = include_str!("static/index.html"); +const APP_JS: &str = include_str!("static/app.js"); +const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; \ + connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + +// ───────────────────────── Защита UI (CSRF / DNS-rebinding) ───────────────────────── +pub struct UiGuard { + pub token: String, + pub cookie_name: String, + pub hosts: Vec, +} + +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +fn cookie_ok(headers: &HeaderMap, g: &UiGuard) -> bool { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|s| s.split(';')) + .any(|kv| { + kv.trim() + .strip_prefix(g.cookie_name.as_str()) + .and_then(|r| r.strip_prefix('=')) + .map_or(false, |v| ct_eq(v.as_bytes(), g.token.as_bytes())) + }) +} + +fn with_security_headers(mut resp: Response) -> Response { + let items: [(HeaderName, &'static str); 5] = [ + (header::CONTENT_SECURITY_POLICY, CSP), + (HeaderName::from_static("x-content-type-options"), "nosniff"), + (header::X_FRAME_OPTIONS, "DENY"), + (header::REFERRER_POLICY, "no-referrer"), + (header::CACHE_CONTROL, "no-store"), + ]; + let h = resp.headers_mut(); + for (k, v) in items { + h.entry(k).or_insert(HeaderValue::from_static(v)); // не перетираем заголовки обработчика + } + resp +} + +/// 1) Host только 127.0.0.1/localhost:порт (анти-rebinding) +/// 2) POST только с Origin нашего же хоста (анти-CSRF) +/// 3) Доступ по токену из консоли: /?t=… выставляет HttpOnly SameSite=Strict cookie +pub async fn ui_guard(State(g): State>, req: Request, next: Next) -> Response { + let headers = req.headers(); + + let host_ok = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .map_or(false, |h| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(h))); + if !host_ok { + return StatusCode::FORBIDDEN.into_response(); + } + + if !matches!(*req.method(), Method::GET | Method::HEAD) { + let origin_ok = headers + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + .and_then(|o| o.strip_prefix("http://")) + .map_or(false, |o| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(o))); + if !origin_ok { + return StatusCode::FORBIDDEN.into_response(); + } + } + + if req.uri().path() == "/" { + if let Some(q) = req.uri().query() { + let token_ok = q + .split('&') + .filter_map(|kv| kv.strip_prefix("t=")) + .any(|t| ct_eq(t.as_bytes(), g.token.as_bytes())); + if token_ok { + let mut resp = StatusCode::SEE_OTHER.into_response(); + resp.headers_mut().insert(header::LOCATION, HeaderValue::from_static("/")); + if let Ok(c) = HeaderValue::from_str(&format!( + "{}={}; Path=/; HttpOnly; SameSite=Strict", + g.cookie_name, g.token + )) { + resp.headers_mut().insert(header::SET_COOKIE, c); + } + return with_security_headers(resp); + } + } + } + + if !cookie_ok(headers, &g) { + return with_security_headers( + (StatusCode::FORBIDDEN, "Откройте адрес из консоли (с токеном ?t=…)").into_response(), + ); + } + with_security_headers(next.run(req).await) +} + +// ───────────────────────── Статика и состояние ───────────────────────── pub async fn ui_index() -> Html<&'static str> { Html(INDEX_HTML) } -pub async fn ui_get_state(State(state): State>) -> Json { - let mut peers_list: Vec = - state.peers.read().unwrap().values().cloned().collect(); - peers_list.sort_by(|a, b| a.name.cmp(&b.name)); +pub async fn ui_js() -> impl IntoResponse { + ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], APP_JS) +} - let texts: Vec = state.ram_texts.read().unwrap().iter().cloned().collect(); - let media: Vec = state.disk_media.read().unwrap().iter().cloned().collect(); - let transfers: Vec = state.transfers.read().unwrap().clone(); - let scan = state.scan_status.read().unwrap().clone(); - let logs: Vec = state.logs.read().unwrap().iter().cloned().collect(); +pub async fn ui_get_state(State(state): State>) -> Json { + let mut peers: Vec = state.peers.read().values().cloned().collect(); + peers.sort_by(|a, b| a.name.cmp(&b.name)); + let texts: Vec = state.ram_texts.read().iter().cloned().collect(); + let media: Vec = state.disk_media.read().iter().cloned().collect(); + let transfers: Vec = state.transfers.read().clone(); + let scan = state.scan_status.read().clone(); Json(serde_json::json!({ "device_name": state.device_name, "my_ip": state.my_ip, "p2p_port": state.p2p_port, "my_fingerprint": state.my_fingerprint, - "my_pin": state.my_fingerprint[..8].to_uppercase(), - "peers": peers_list, + "my_pin": short_id(&state.my_fingerprint), + "media_dir": state.media_dir.display().to_string(), + "peers": peers, "texts": texts, "media": media, "transfers": transfers, "scan": scan, - "logs": logs })) } +// ───────────────────────── Устройства ───────────────────────── pub async fn ui_add_manual_peer( State(state): State>, Json(req): Json, -) -> Result { - let ip = req.ip.trim().to_string(); - let port = req.port.unwrap_or(state.p2p_port); - - let url = format!("https://{}:{}/p2p/info", ip, port); - let resp = state - .probe_client - .get(&url) - .header("X-Node-Fp", &state.my_fingerprint) - .header("X-Node-Name", &state.device_name) - .header("X-Node-Ip", &state.my_ip) - .header("X-Node-Port", state.p2p_port.to_string()) - .send() - .await - .map_err(|e| (StatusCode::BAD_REQUEST, format!("Узел {} недоступен: {}", ip, e)))?; - - if !resp.status().is_success() { - return Err((StatusCode::BAD_GATEWAY, "Удаленный узел вернул ошибку".into())); +) -> Result { + let ip: IpAddr = req + .ip + .trim() + .parse() + .map_err(|_| AppError::new(StatusCode::BAD_REQUEST, "Некорректный IP-адрес"))?; + match ip { + IpAddr::V4(v4) if is_lan_v4(v4) => {} + _ => return Err(AppError::new(StatusCode::BAD_REQUEST, "Разрешены только локальные IPv4-адреса")), } - - let info: serde_json::Value = resp - .json() + let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port); + net::probe_and_register(&state, ip, port) .await - .map_err(|_| (StatusCode::BAD_GATEWAY, "Некорректный JSON от узла".into()))?; - - let name = info["name"].as_str().unwrap_or("Remote-Node").to_string(); - let fingerprint = info["fingerprint"].as_str().unwrap_or("").to_string(); - let remote_port = info["port"].as_u64().unwrap_or(port as u64) as u16; - - if fingerprint.len() < 8 { - return Err((StatusCode::BAD_GATEWAY, "Недействительный отпечаток сертификата".into())); - } - - state.upsert_peer(name, ip, remote_port, fingerprint); + .map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Узел {}: {}", ip, e)))?; Ok(StatusCode::OK) } -/// Асинхронное сканирование всего или части диапазона IP-адресов pub async fn ui_scan_range( State(state): State>, Json(req): Json, -) -> Result { - { - let scan = state.scan_status.read().unwrap(); - if scan.active { - return Err((StatusCode::CONFLICT, "Сканирование уже выполняется".into())); - } - } - - let port = req.port.unwrap_or(state.p2p_port); - let (range_label, ips) = - parse_ip_range(&req.range, &state.my_ip).map_err(|e| (StatusCode::BAD_REQUEST, e))?; - +) -> Result { + let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port); + let (label, ips) = + parse_ip_range(&req.range, &state.my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?; let total = ips.len(); + { - let mut scan = state.scan_status.write().unwrap(); - *scan = ScanProgress { + // проверка и установка флага — атомарно, под одним write-замком + let mut s = state.scan_status.write(); + if s.active { + return Err(AppError::new(StatusCode::CONFLICT, "Сканирование уже выполняется")); + } + *s = ScanProgress { active: true, - range: range_label.clone(), + range: label.clone(), scanned: 0, total, found: 0, }; } + state.add_log(format!("Запущено сканирование {} ({} адресов, порт {})", label, total, port)); - state.add_log(format!( - "Запущено сканирование диапазона {} ({} адресов, порт {})", - range_label, total, port - )); - - let state_bg = state.clone(); + let st = state.clone(); + let worker = tokio::spawn(net::run_scan(state.clone(), ips, port)); tokio::spawn(async move { - let semaphore = Arc::new(Semaphore::new(48)); - let mut handles = Vec::with_capacity(total); - - for ip in ips { - let sem = semaphore.clone(); - let st = state_bg.clone(); - handles.push(tokio::spawn(async move { - let _permit = sem.acquire().await.unwrap(); - - // Быстрая проверка открытости TCP-порта (350 мс) перед HTTPS-рукопожатием - let sock_addr: Option = format!("{}:{}", ip, port).parse().ok(); - let mut found_node = false; - - if let Some(addr) = sock_addr { - if tokio::time::timeout( - Duration::from_millis(350), - tokio::net::TcpStream::connect(addr), - ) - .await - .map(|r| r.is_ok()) - .unwrap_or(false) - { - let url = format!("https://{}:{}/p2p/info", ip, port); - if let Ok(resp) = st - .probe_client - .get(&url) - .header("X-Node-Fp", &st.my_fingerprint) - .header("X-Node-Name", &st.device_name) - .header("X-Node-Ip", &st.my_ip) - .header("X-Node-Port", st.p2p_port.to_string()) - .send() - .await - { - if let Ok(info) = resp.json::().await { - let name = info["name"].as_str().unwrap_or("Node").to_string(); - let fp = info["fingerprint"].as_str().unwrap_or("").to_string(); - let r_port = info["port"].as_u64().unwrap_or(port as u64) as u16; - if fp.len() >= 8 { - st.upsert_peer(name, ip, r_port, fp); - found_node = true; - } - } - } - } - } - - let mut scan = st.scan_status.write().unwrap(); - scan.scanned += 1; - if found_node { - scan.found += 1; - } - })); - } - - for h in handles { - let _ = h.await; - } - - let found_total = { - let mut scan = state_bg.scan_status.write().unwrap(); - scan.active = false; - scan.found + let _ = worker.await; // даже при панике воркера флаг active будет сброшен + let found = { + let mut s = st.scan_status.write(); + s.active = false; + s.found }; - - state_bg.add_log(format!( - "Сканирование {} завершено. Найдено устройств: {}", - range_label, found_total - )); + st.add_log(format!("Сканирование {} завершено. Найдено устройств: {}", label, found)); }); - Ok(StatusCode::OK) } pub async fn ui_ping_peers(State(state): State>) -> StatusCode { - let state_clone = state.clone(); - tokio::spawn(async move { - crate::ping_all_peers(&state_clone).await; - }); + tokio::spawn(async move { net::ping_all_peers(&state).await }); StatusCode::OK } pub async fn ui_trust_peer( State(state): State>, Json(req): Json, -) -> Result { - let mut peers = state.peers.write().unwrap(); - let peer = peers - .get_mut(&req.peer_id) - .ok_or((StatusCode::NOT_FOUND, "Устройство не найдено".to_string()))?; - - peer.verified = req.trust; - let msg = if req.trust { - format!("Сертификат '{}' [{}] ЗАВЕРЕН", peer.name, peer.pin_code) - } else { - format!("Доверие устройству '{}' [{}] ОТОЗВАНО", peer.name, peer.pin_code) +) -> Result { + let msg = { + let mut peers = state.peers.write(); + let p = peers + .get_mut(&req.peer_id) + .ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?; + p.verified = req.trust; + if req.trust { + format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code) + } else { + format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code) + } }; - drop(peers); + state.persist_trust(); state.add_log(msg); - Ok(StatusCode::OK) } pub async fn ui_clear_ram(State(state): State>) -> StatusCode { - state.ram_texts.write().unwrap().clear(); + state.ram_texts.write().clear(); state.add_log("Текстовый буфер в ОЗУ очищен"); StatusCode::OK } +// ───────────────────────── Отправка ───────────────────────── +fn resolve_target(state: &AppState, id: &str) -> Result<(Vec, u16, String), AppError> { + let peers = state.peers.read(); + let p = peers + .get(id) + .ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Получатель не найден"))?; + if !p.verified { + return Err(AppError::new( + StatusCode::FORBIDDEN, + format!("Сначала заверьте устройство '{}' (код сверки {})", p.name, p.pin_code), + )); + } + let ips: Vec = p.ips.iter().filter_map(|s| s.parse().ok()).collect(); + if ips.is_empty() { + return Err(AppError::new( + StatusCode::BAD_REQUEST, + "Адрес получателя неизвестен. Выполните опрос или сканирование.", + )); + } + Ok((ips, p.port, p.name.clone())) +} + pub async fn ui_send_text( State(state): State>, Json(req): Json, -) -> Result { - let (target_ip, port, peer_name) = { - let peers = state.peers.read().unwrap(); - let peer = peers - .get(&req.peer_id) - .ok_or((StatusCode::NOT_FOUND, "Получатель не найден".to_string()))?; - if !peer.verified { - return Err(( - StatusCode::FORBIDDEN, - format!( - "Сначала заверьте сертификат устройства '{}' [PIN: {}]!", - peer.name, peer.pin_code - ), - )); - } - let ip = peer - .ips - .first() - .cloned() - .ok_or((StatusCode::BAD_REQUEST, "У получателя нет известного IP".to_string()))?; - (ip, peer.port, peer.name.clone()) - }; - - let url = format!("https://{}:{}/p2p/text", target_ip, port); - let payload = IncomingTextPayload { - sender: state.device_name.clone(), - sender_fp: state.my_fingerprint.clone(), - text: req.text, - }; - - let resp = state - .http_client - .post(&url) - .json(&payload) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?; - - if resp.status().is_success() { - state.add_log(format!("Текст доставлен в ОЗУ устройства '{}'", peer_name)); - Ok(StatusCode::OK) - } else { - Err((resp.status(), "Удаленный узел отклонил запрос".to_string())) +) -> Result { + if req.text.trim().is_empty() { + return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст")); } + if req.text.len() > MAX_TEXT_BYTES { + return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст больше 64 КБ")); + } + let (ips, port, peer_name) = resolve_target(&state, &req.peer_id)?; + let client = state.client_for_peer(&req.peer_id, false)?; + let body = serde_json::to_vec(&IncomingTextPayload { text: req.text }) + .map_err(|e| AppError::internal("json", e))?; + + for ip in ips { + let url = format!("https://{}:{}/p2p/text", ip, port); + match client + .post(&url) + .header(header::CONTENT_TYPE, "application/json") + .body(body.clone()) + .send() + .await + { + Ok(r) if r.status().is_success() => { + state.add_log(format!("Текст доставлен в ОЗУ устройства '{}'", peer_name)); + return Ok(StatusCode::OK); + } + Ok(r) if r.status() == StatusCode::FORBIDDEN => { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Получатель ещё не заверил ваше устройство (доверие должно быть взаимным)", + )) + } + Ok(r) => { + return Err(AppError::new( + StatusCode::BAD_GATEWAY, + format!("Получатель отклонил запрос ({})", r.status()), + )) + } + Err(_) => continue, // пробуем следующий адрес + } + } + Err(AppError::new( + StatusCode::BAD_GATEWAY, + "Не удалось установить защищённое соединение (узел недоступен или сертификат не совпал)", + )) } -/// Почанковая отправка медиафайла с реальным отслеживанием прогресса по сети +/// Потоковая отправка: браузер → (канал с backpressure) → TLS → получатель. Память O(1). pub async fn ui_send_media( State(state): State>, headers: HeaderMap, - body: Bytes, -) -> Result { - let peer_id = headers - .get("X-Target-Id") - .and_then(|v| v.to_str().ok()) - .ok_or((StatusCode::BAD_REQUEST, "Не указан ID получателя".to_string()))?; - - let raw_filename = headers - .get("X-Filename") - .and_then(|v| v.to_str().ok()) - .unwrap_or("file.bin") + body: Body, +) -> Result { + let peer_id = hdr(&headers, "x-target-id") + .ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))? .to_string(); + let display_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin"))); + let total: u64 = hdr(&headers, "content-length") + .and_then(|v| v.parse().ok()) + .ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?; + if total == 0 { + return Err(AppError::new(StatusCode::BAD_REQUEST, "Файл пустой")); + } + if total > MAX_FILE_SIZE { + return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Файл больше 2 ГБ")); + } - let display_name = sanitize_filename(&url_decode(&raw_filename)); + let (ips, port, peer_name) = resolve_target(&state, &peer_id)?; + let client = state.client_for_peer(&peer_id, true)?; + let url = format!("https://{}:{}/p2p/media", ips[0], port); - let mime = headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - let (target_ip, port, peer_name) = { - let peers = state.peers.read().unwrap(); - let peer = peers - .get(peer_id) - .ok_or((StatusCode::NOT_FOUND, "Получатель не найден".to_string()))?; - if !peer.verified { - return Err(( - StatusCode::FORBIDDEN, - format!( - "Сначала заверьте сертификат устройства '{}' [PIN: {}]!", - peer.name, peer.pin_code - ), - )); - } - let ip = peer - .ips - .first() - .cloned() - .ok_or((StatusCode::BAD_REQUEST, "У получателя нет IP".to_string()))?; - (ip, peer.port, peer.name.clone()) - }; - - let total_size = body.len(); - let transfer_id = format!("tx_{}_{}", now_sec(), total_size); - let url = format!("https://{}:{}/p2p/media", target_ip, port); - - // Адаптивный размер чанка для плавного обновления прогресс-бара на любых файлах - let chunk_size = if total_size < 1024 * 1024 { - 32 * 1024 // 32 КБ для файлов до 1 МБ - } else if total_size < 15 * 1024 * 1024 { - 128 * 1024 // 128 КБ для файлов до 15 МБ - } else { - 512 * 1024 // 512 КБ для крупных файлов - }; - - state.upsert_transfer(TransferProgress { - id: transfer_id.clone(), + let tid = format!("tx_{}", random_hex(6)); + state.add_transfer(TransferProgress { + id: tid.clone(), filename: display_name.clone(), peer_name: peer_name.clone(), - direction: "out".to_string(), + direction: Direction::Out, transferred_bytes: 0, - total_bytes: total_size, + total_bytes: total, percent: 0, done: false, error: None, updated_at: now_sec(), }); - let mut offset = 0usize; - loop { - let end = (offset + chunk_size).min(total_size); - let chunk = body.slice(offset..end); - - let send_res = state - .http_client - .post(&url) - .header("X-Sender", &state.device_name) - .header("X-Filename", &raw_filename) - .header("X-Transfer-Id", &transfer_id) - .header("X-Offset", offset.to_string()) - .header("X-Total-Size", total_size.to_string()) - .header(header::CONTENT_TYPE, &mime) - .body(chunk) - .send() - .await; - - match send_res { - Ok(resp) if resp.status().is_success() => { - offset = end; - let is_done = offset >= total_size; - let percent = if total_size == 0 { - 100 - } else { - ((offset as f64 / total_size as f64) * 100.0).min(100.0) as u8 - }; - - state.upsert_transfer(TransferProgress { - id: transfer_id.clone(), - filename: display_name.clone(), - peer_name: peer_name.clone(), - direction: "out".to_string(), - transferred_bytes: offset, - total_bytes: total_size, - percent, - done: is_done, - error: None, - updated_at: now_sec(), - }); - - if is_done { - break; + let (tx, rx) = tokio::sync::mpsc::channel::>(4); + let fwd_state = state.clone(); + let fwd_id = tid.clone(); + let fwd = tokio::spawn(async move { + let mut stream = std::pin::pin!(body.into_data_stream()); + let mut sent: u64 = 0; + let mut last = Instant::now(); + while let Some(item) = stream.next().await { + let chunk = match item { + Ok(c) => c, + Err(_) => { + let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "client aborted"))).await; + return; } + }; + sent += chunk.len() as u64; + if sent > total { + let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "body too long"))).await; + return; } - Ok(resp) => { - let err_msg = format!("Узел отклонил чанк (код {})", resp.status()); - state.remove_transfer(&transfer_id); - return Err((resp.status(), err_msg)); + // если получатель не читает 30 с — закрываем поток, запрос завершится ошибкой + match tokio::time::timeout(Duration::from_secs(30), tx.send(Ok(chunk))).await { + Ok(Ok(())) => {} + _ => return, } - Err(e) => { - let err_msg = format!("Ошибка сети при передаче файла: {}", e); - state.remove_transfer(&transfer_id); - return Err((StatusCode::BAD_GATEWAY, err_msg)); + if last.elapsed() >= Duration::from_millis(100) { + fwd_state.update_transfer_bytes(&fwd_id, sent); + last = Instant::now(); } } - } - - state.add_log(format!( - "Медиафайл '{}' ({} байт) успешно доставлен на '{}'", - display_name, total_size, peer_name - )); - - let st_cleanup = state.clone(); - let tid_cleanup = transfer_id.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(4)).await; - st_cleanup.remove_transfer(&tid_cleanup); }); - Ok(StatusCode::OK) -} + let result = client + .post(&url) + .header("x-filename", pct_encode(&display_name)) + .header("x-total-size", total.to_string()) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, total.to_string()) + .body(reqwest::Body::wrap_stream(ReceiverStream::new(rx))) + .send() + .await; + fwd.abort(); -pub async fn ui_serve_saved_file(Path(filename): Path) -> impl IntoResponse { - let safe_name = sanitize_filename(&filename); - let path = PathBuf::from(MEDIA_DIR).join(&safe_name); - match tokio::fs::read(&path).await { - Ok(bytes) => (StatusCode::OK, bytes).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "Файл не найден на диске").into_response(), + let fail = |msg: &str, status: StatusCode| { + state.fail_transfer(&tid, msg.to_string()); + remove_transfer_later(&state, tid.clone(), 10); + AppError::new(status, msg) + }; + + match result { + Ok(r) if r.status().is_success() => { + state.finish_transfer(&tid); + remove_transfer_later(&state, tid.clone(), 4); + state.add_log(format!( + "Файл '{}' ({} байт) доставлен на '{}'", + display_name, total, peer_name + )); + Ok(StatusCode::OK) + } + Ok(r) if r.status() == StatusCode::FORBIDDEN => Err(fail( + "Получатель ещё не заверил ваше устройство", + StatusCode::FORBIDDEN, + )), + Ok(r) => Err(fail( + &format!("Получатель отклонил передачу (код {})", r.status()), + StatusCode::BAD_GATEWAY, + )), + Err(_) => Err(fail( + "Ошибка сети/TLS при передаче (узел недоступен, сертификат не совпал или обрыв)", + StatusCode::BAD_GATEWAY, + )), } } + +// ───────────────────────── Раздача принятых файлов ───────────────────────── +pub async fn ui_serve_saved_file(State(state): State>, Path(filename): Path) -> Response { + let not_found = || (StatusCode::NOT_FOUND, "Файл не найден").into_response(); + if filename.is_empty() + || filename.starts_with('.') + || filename.contains(|c: char| matches!(c, '/' | '\\' | '\0')) + { + return not_found(); + } + let path = state.media_dir.join(&filename); + let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() }; + if !meta.is_file() { + return not_found(); + } + let Ok(file) = tokio::fs::File::open(&path).await else { return not_found() }; + + let inline = storage::is_previewable(&filename); + // Content-Type определяем САМИ по расширению, а не по заявлению отправителя + let ctype = if inline { storage::mime_from_name(&filename) } else { "application/octet-stream" }; + let disposition = format!( + "{}; filename*=UTF-8''{}", + if inline { "inline" } else { "attachment" }, + pct_encode(&filename) + ); + + let mut resp = Response::new(Body::from_stream(ReaderStream::new(file))); + let h = resp.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype)); + h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len())); + if let Ok(v) = HeaderValue::from_str(&disposition) { + h.insert(header::CONTENT_DISPOSITION, v); + } + h.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + h.insert( + HeaderName::from_static("cross-origin-resource-policy"), + HeaderValue::from_static("same-origin"), + ); + resp +} diff --git a/target/aarch64-linux-android/release/ramshare_ui b/target/aarch64-linux-android/release/ramshare_ui index 2bd01b1..6ef7d34 100755 Binary files a/target/aarch64-linux-android/release/ramshare_ui and b/target/aarch64-linux-android/release/ramshare_ui differ