mirror of
https://github.com/2dust/v2rayN.git
synced 2026-09-26 16:42:08 +03:00
Compare commits
868 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c462ec7b | |||
| 526cd6834d | |||
| 7be23d7ebc | |||
| d952c180c9 | |||
| 80fa6575a3 | |||
| f05e44d7d9 | |||
| b10c16a94a | |||
| 143ada1358 | |||
| d8c7bd9d02 | |||
| 85666af36a | |||
| 7286c67a78 | |||
| 8b8e579bc8 | |||
| ed4dd7f6d9 | |||
| bb62c9eda5 | |||
| 3d50d9f82c | |||
| d05c3439f8 | |||
| cb5ae8c900 | |||
| ec6a7b3db2 | |||
| 99328a14d2 | |||
| 8ebbd7f4f8 | |||
| b8e8321603 | |||
| fe2e219b7a | |||
| df32b4d6cc | |||
| 9451b69246 | |||
| 4a59c7f4d6 | |||
| fed59a7313 | |||
| 093b8d4c34 | |||
| f03db020fa | |||
| f62eaab8c5 | |||
| 1bf3d14771 | |||
| 64aad8027a | |||
| 7870ae89cf | |||
| 521230c40d | |||
| 426d5d5a21 | |||
| 3f14c86a04 | |||
| 70b8ecd3c8 | |||
| 0d452328a2 | |||
| 2a65e5ac80 | |||
| f332caf507 | |||
| 4b412e246f | |||
| 09ac4d181a | |||
| c575527725 | |||
| 589ccc6e06 | |||
| 0a2f3e4f22 | |||
| 007adb6403 | |||
| e50c0a9170 | |||
| af0eb9ed14 | |||
| 0436081436 | |||
| 2a89a2e597 | |||
| 2be63d1655 | |||
| 74bd28bbd0 | |||
| be501cbd2a | |||
| e20b4dbfab | |||
| 728cbe70f2 | |||
| 26a86f9bd2 | |||
| c66c55b05e | |||
| ebb4bd5daa | |||
| 14e27f3d23 | |||
| 2d514ff480 | |||
| 11bd52b6a5 | |||
| 3b3fe8def3 | |||
| c9e843aa3b | |||
| 7be264c65c | |||
| 5e6660aeee | |||
| 5b27f4836f | |||
| e0ad2c46e6 | |||
| 230a2f6773 | |||
| e7cc4c832e | |||
| fb7033aab5 | |||
| b6cc3913ac | |||
| bc06a5dc21 | |||
| 9c1951178c | |||
| b3df42b9ba | |||
| e01717d832 | |||
| 8b4063b44b | |||
| 3136f573dd | |||
| e101b1d7b0 | |||
| 31044f449d | |||
| eacf8b0784 | |||
| f347fe1c03 | |||
| 71208eb711 | |||
| 339eaa6bfd | |||
| 717ef7f0a7 | |||
| 4a9d83f9dc | |||
| 1ac14d0903 | |||
| 08d423e1b4 | |||
| 9638b4c368 | |||
| 1034ce6e02 | |||
| 5efff37acb | |||
| e8cdd1cc02 | |||
| d381d429e4 | |||
| b27ae11d1e | |||
| eff584597f | |||
| ccf18ba0fb | |||
| 15ba2bdf93 | |||
| e1e6c5ddb0 | |||
| d924c6f557 | |||
| 635a04d74c | |||
| 2e1bb23da7 | |||
| 6f3bacd5b4 | |||
| 074ce5de04 | |||
| 02df430172 | |||
| 611ce0fd2c | |||
| a846e74e8f | |||
| 92c3df45ae | |||
| ee7e21268a | |||
| dc216c2b02 | |||
| beffa919d3 | |||
| a654826231 | |||
| b01476d147 | |||
| cf8dd45abe | |||
| 9bba6f53f8 | |||
| cd77f1d882 | |||
| 0427037638 | |||
| e749b81ecf | |||
| fd2c942231 | |||
| fa42eb670f | |||
| 0d42996573 | |||
| a37772f12b | |||
| 531e0e9443 | |||
| 984c77f0c4 | |||
| b5e0be47a5 | |||
| 6df0e6de1b | |||
| 2b5328665f | |||
| 4584ecc5c9 | |||
| d9dc2d7cf5 | |||
| 36f8565b7a | |||
| f55d8b2565 | |||
| 09ea4890a7 | |||
| 5cc2aaba13 | |||
| b8889bad86 | |||
| 5dd5b25869 | |||
| fafdb4a0b6 | |||
| 223642dd67 | |||
| 165e0bf9e9 | |||
| e615314582 | |||
| ca9978b625 | |||
| 74ab7ad097 | |||
| 8f5cbad988 | |||
| 1ab9757498 | |||
| 50ab02e8a9 | |||
| b9a1c129c4 | |||
| 996eb51f9b | |||
| b9a58e7137 | |||
| 9664ee380e | |||
| 6f50206606 | |||
| 1de83f96ed | |||
| 994bc1ca6a | |||
| 3ca49dd9db | |||
| a0bd8f9934 | |||
| 88d59488fd | |||
| 2c70b018ce | |||
| b6ab428dfc | |||
| 45ab7503e3 | |||
| 1768b4a7ee | |||
| 3e9f78f831 | |||
| 8c4bd52389 | |||
| fa61fedf11 | |||
| ad12a6c456 | |||
| 66b48c231f | |||
| 52766d66bf | |||
| e1a5c360ac | |||
| 0c570fcb38 | |||
| a236fab27c | |||
| f8e1186095 | |||
| ca186e2636 | |||
| 1e8ba9ad9b | |||
| 31e32f174e | |||
| bf376a8fac | |||
| 5a9a9b7ab1 | |||
| e7a9aa66dc | |||
| 709b9a0958 | |||
| 191fe48257 | |||
| 48d58d482a | |||
| 7100b3f0a5 | |||
| 8d94997ba0 | |||
| c97dc1d894 | |||
| b198fd8be7 | |||
| e0c1ff24be | |||
| 3cebf492d1 | |||
| ef46f4e7e6 | |||
| 81c118c9b2 | |||
| f1b88ccf2a | |||
| ce380b861c | |||
| 46e9e4fead | |||
| 00903d3531 | |||
| 616eea39f9 | |||
| ff456a9389 | |||
| 0d225cd29d | |||
| 67426cff12 | |||
| a8c27027b4 | |||
| 1a681695a2 | |||
| 2126ea5a87 | |||
| be005abf9e | |||
| da7dfde706 | |||
| 7491e86d80 | |||
| af037ed087 | |||
| c9298db607 | |||
| da81101cfd | |||
| 3d4f202cd7 | |||
| 35fbb137e8 | |||
| 1869a95700 | |||
| 34359885ab | |||
| 93832656dd | |||
| b1a400b393 | |||
| 879c736924 | |||
| 8147b39723 | |||
| 5fed566a32 | |||
| f0ee792778 | |||
| cd4aa5531c | |||
| e6d08c2db1 | |||
| 4dc9c60b85 | |||
| eb9c974436 | |||
| 27b8f889fe | |||
| 0d5169e4cd | |||
| abcdd9497c | |||
| da65faa49f | |||
| b6cfce069a | |||
| 39ec9239d2 | |||
| a2a5940d91 | |||
| ce8caa89a4 | |||
| af3d579890 | |||
| b2bbc865af | |||
| 2eb325643a | |||
| 8609e2b8d1 | |||
| fcf6c1e3ae | |||
| ab907e371b | |||
| ef4dda0996 | |||
| f2e0bd4eba | |||
| bca07f801d | |||
| 54a3388c99 | |||
| 5140954b37 | |||
| c7efe39e1c | |||
| a74ca3f9d0 | |||
| ed61f5c420 | |||
| bf98c4007f | |||
| 78dcf51c3c | |||
| 1e59344074 | |||
| 1d2442d58a | |||
| 3448782925 | |||
| e6f4a57913 | |||
| 4ae5c021fd | |||
| 0a357fd1a7 | |||
| 5ba5a805ce | |||
| 807f0aba06 | |||
| f4a2086dfb | |||
| ccb0ffb3b6 | |||
| 1fa1246c0b | |||
| 14cc37d07a | |||
| c7519f8ea7 | |||
| 0c796a157b | |||
| a9824fe6ec | |||
| f18758d4bf | |||
| c1a009a409 | |||
| 0f3fc8e053 | |||
| f7206f3405 | |||
| 460a674ebc | |||
| e2428f2500 | |||
| bc3cbb4277 | |||
| ac9d0a0193 | |||
| 2291214b6f | |||
| 5b47d8ba05 | |||
| b193c39ad7 | |||
| e68842bb78 | |||
| 6c06c8a00a | |||
| 58d2641559 | |||
| 780777068d | |||
| 8446b4df8b | |||
| 3778d2058e | |||
| 5c85598cfa | |||
| 51b384e119 | |||
| d1c9c0b536 | |||
| cc57f952db | |||
| 8090799ccc | |||
| 700f98193a | |||
| eee43288a4 | |||
| 61ff871dd2 | |||
| f8bc706cda | |||
| e7973840ce | |||
| 212071681d | |||
| 0f9bfeb275 | |||
| f5059f1165 | |||
| 2dc967bc04 | |||
| 75ea81dd69 | |||
| d13f7a4db6 | |||
| f073b14fcc | |||
| 1a44af33d0 | |||
| 8450f2e420 | |||
| 37ef25cbfe | |||
| 0fac18ba95 | |||
| 3ccd59d1dc | |||
| 6c38a08f12 | |||
| f8f7fee461 | |||
| 3e157b0d62 | |||
| 49d197e37f | |||
| b6f2912f29 | |||
| 05e349e45c | |||
| ae662a628d | |||
| 6e85f79852 | |||
| 0af29f50c0 | |||
| ee6ae3d91d | |||
| c4e071cac3 | |||
| 798831128a | |||
| a2de087aef | |||
| 89bc012c95 | |||
| 39ef5d8174 | |||
| 90b055e364 | |||
| d67321eed0 | |||
| 67592d1922 | |||
| c5db319e0e | |||
| 6a7b359fcc | |||
| 25d7f393b6 | |||
| 171ed6f58f | |||
| b604a5b787 | |||
| 35b98f945f | |||
| cabd0df282 | |||
| eeecef4db9 | |||
| 021e64e20b | |||
| 452478434c | |||
| 5305b0843b | |||
| 9f0ef36cc0 | |||
| 494b35c1f7 | |||
| 177ad7db3d | |||
| 3bda022574 | |||
| 2ea9c5a2ff | |||
| 3cb2869920 | |||
| 43dcb90632 | |||
| e56fca05fc | |||
| 495b5db4f1 | |||
| dea143b20d | |||
| 0db611b7a9 | |||
| c3d67d186a | |||
| 6b07ca63a0 | |||
| 6c8f22ab86 | |||
| 49f65579aa | |||
| a69e407bda | |||
| 96e5c11fc7 | |||
| 53041906b3 | |||
| f2d929f40e | |||
| 1160d8c154 | |||
| 75f2cbaef9 | |||
| 55b08de5fe | |||
| 14d598a232 | |||
| b3102b34b3 | |||
| b556adaa09 | |||
| fce86e1434 | |||
| 70003e8a81 | |||
| e19b000081 | |||
| 7329dbae11 | |||
| 695a073cd6 | |||
| 01c85adedf | |||
| 2caf8ea14f | |||
| 1090afd774 | |||
| c758c5abf9 | |||
| c61b023ab3 | |||
| 80178aeb2f | |||
| 005cb620ec | |||
| 7ddb46e74d | |||
| ad11a7e6a5 | |||
| 92c8c1463c | |||
| 661affd6a5 | |||
| 14c5b92423 | |||
| 74e5ead1ed | |||
| 2caec729fc | |||
| 194c240243 | |||
| db9fe9c5ea | |||
| bbfd93f5a3 | |||
| 04783ecf44 | |||
| 5fbcc46013 | |||
| 90f7b8b751 | |||
| dd94199bbb | |||
| 0cec5986cd | |||
| a2929c6086 | |||
| eb0ef90ed2 | |||
| 214a09bc48 | |||
| e6af9ab342 | |||
| 0f4031f445 | |||
| 5cf3d6eff6 | |||
| 17ed26cd06 | |||
| 5e18567ce6 | |||
| 06cec89ec9 | |||
| 26f65dd3b2 | |||
| 0c2114d2e1 | |||
| 4af528f8e2 | |||
| 588e82f0d9 | |||
| 0c13488410 | |||
| a88396c11d | |||
| ef5fee9975 | |||
| df800a60c2 | |||
| 679bd8afcc | |||
| 66e1aeae1f | |||
| e03c22092f | |||
| c0aa829abb | |||
| b8f7cc0768 | |||
| 81da72bb39 | |||
| d9201157c8 | |||
| e179d5bc42 | |||
| 4d2f32099e | |||
| f24a79aa2c | |||
| 9a3604e89b | |||
| fd7cf0d453 | |||
| 65cf782eb0 | |||
| bfa9eaa5ec | |||
| cea725ae3d | |||
| c9df9a0001 | |||
| 56f1794e47 | |||
| a71ebbd01c | |||
| 9f6237fb21 | |||
| 99d67ca3f1 | |||
| d56e896f07 | |||
| 6b4ae5a386 | |||
| a3ff31088e | |||
| 584e538623 | |||
| 67c4ae02ba | |||
| ed1275e29f | |||
| 0cf07e925f | |||
| 49e487886d | |||
| ad07f281c7 | |||
| f98f517368 | |||
| 7931058342 | |||
| b53507f486 | |||
| 68ea10158a | |||
| 2f35e7a99c | |||
| 3c1ecf085b | |||
| 3a5293bf87 | |||
| ac43bb051d | |||
| 7b31bcdd9f | |||
| aea7078e95 | |||
| 9c82df5b49 | |||
| b5800f7dfc | |||
| 0f3a3eac02 | |||
| 54608ab2b9 | |||
| 6167624443 | |||
| 7a58e78381 | |||
| 677e81f9a7 | |||
| d9843dc775 | |||
| bceebc1661 | |||
| fdb733fa72 | |||
| 8d01d8fda5 | |||
| 018d541910 | |||
| 7e2e66bb0e | |||
| 3cb640c16b | |||
| fdde837698 | |||
| c7afef3d70 | |||
| 19d4f1fa83 | |||
| 7678ad9095 | |||
| 585c24526f | |||
| eb0f5bafde | |||
| d589713fd5 | |||
| 4550ddb14e | |||
| ffe401a26d | |||
| 8774e302b2 | |||
| df016dd55c | |||
| 9ea80671d3 | |||
| 449849d8e8 | |||
| 03b62b3d78 | |||
| 9f9b90cb97 | |||
| c42dcd2876 | |||
| 2fefafdd37 | |||
| 2c9a90c878 | |||
| 4e5f1838a2 | |||
| a45a1dc982 | |||
| fe183798b6 | |||
| 947c84cf10 | |||
| 9c74b51d74 | |||
| abd962ab31 | |||
| f3b894015e | |||
| 4562d4cf00 | |||
| bc36cf8a47 | |||
| cbdfe2e15a | |||
| 68583e20bc | |||
| 6d6459b009 | |||
| 807562b69e | |||
| 654d7d83d0 | |||
| 027252e687 | |||
| 5478c90180 | |||
| 28f30d7e97 | |||
| ae7d54c2e5 | |||
| 56d0d65b06 | |||
| 5e8e189c27 | |||
| 3fee86d44a | |||
| dd77eb79c6 | |||
| d26a2559a6 | |||
| e5ba1759aa | |||
| bfdee37cc1 | |||
| cf89cfcd95 | |||
| 39a988c704 | |||
| 2b28254fbc | |||
| 6e27dca6cd | |||
| 7cee98887b | |||
| 3885ff8b31 | |||
| 12abf383e9 | |||
| 5bef02bd6d | |||
| 592f1260b5 | |||
| 18303688d7 | |||
| 5c4b7f6636 | |||
| 37cce2fa35 | |||
| 6f8b65c75b | |||
| 83c63b914a | |||
| 1ca2485d2a | |||
| cc4154bb0d | |||
| d7f77f220c | |||
| 86f45d103d | |||
| 0077751f75 | |||
| fa2b4b3dc9 | |||
| 23cacb8339 | |||
| 9ffa6a4eb6 | |||
| 386209b835 | |||
| 830dc89c32 | |||
| 693afe3560 | |||
| 95361e8b65 | |||
| 3ff7299aca | |||
| 34fc4de0c2 | |||
| 91536d3923 | |||
| 6b87c09a96 | |||
| ecaac2ac61 | |||
| ad74b1584d | |||
| 514d76953a | |||
| 5b82f17995 | |||
| 20ce35bc30 | |||
| c0fca0dddd | |||
| 2ba896e17e | |||
| f61e6d8c63 | |||
| d3e2e55ecf | |||
| 30e663cd4f | |||
| 054efeb32c | |||
| 2ebd2b28a8 | |||
| 84f812c8ee | |||
| b6ee40ab8d | |||
| 7f24f4a15f | |||
| 0d307671d1 | |||
| 8ea5a57988 | |||
| 4fb41aeca1 | |||
| 3f0bcf7b83 | |||
| 7e712fcdeb | |||
| e634e6dae3 | |||
| 24f8d767b1 | |||
| 31a8ddef23 | |||
| 30e9e64fd5 | |||
| f677934257 | |||
| df7ca81837 | |||
| 53bd03dea2 | |||
| 1f8dd1a52d | |||
| d5460d758b | |||
| 6e38357b7d | |||
| 1990850d9a | |||
| e6cb146671 | |||
| 4da59cd767 | |||
| e20c11c1a7 | |||
| a6af95e083 | |||
| 6f06b16c76 | |||
| 70ddf4ecfc | |||
| 187356cb9e | |||
| 32583ea8b3 | |||
| 69797c10f2 | |||
| ddc8c9b1cd | |||
| 753e7b81b6 | |||
| 725b094fb1 | |||
| 6de5a5215d | |||
| 8d86aa2b72 | |||
| 1aee3950f4 | |||
| 091b79f7cf | |||
| ed2c77062e | |||
| 8b1105c7e2 | |||
| 11c203ad19 | |||
| ab6a6b879e | |||
| b218f0b501 | |||
| 7b5686cd8f | |||
| d727ff40bb | |||
| 1b5069a933 | |||
| 18ea6fdc00 | |||
| 67494108ad | |||
| 38b2a7d2ca | |||
| bf3703bca1 | |||
| 554632cc07 | |||
| 12fc3e9566 | |||
| c2ef3a4a8c | |||
| 86eb8297dd | |||
| c63d4e83f9 | |||
| bf1fb0f92e | |||
| 3c4865982b | |||
| 22c233f0cd | |||
| b2d6282755 | |||
| c8d89e3dce | |||
| d3b1810eab | |||
| 51409a3e28 | |||
| 1a0f50a41e | |||
| 83d4a9c18e | |||
| b4c20e7b81 | |||
| 7c76308c93 | |||
| f28fa31c14 | |||
| bbedc4dbb1 | |||
| ecf42cb85d | |||
| e4701d6703 | |||
| 54a47d00a3 | |||
| 964572817b | |||
| 10358064dc | |||
| 6a19896915 | |||
| 07e173eab1 | |||
| 91bca3a7ae | |||
| 20260412a7 | |||
| bca030002f | |||
| 479bf8e037 | |||
| cb5069bcfc | |||
| eb1339f2f5 | |||
| b66bfabd21 | |||
| 3555d861ae | |||
| f39bc6d3b0 | |||
| 0c0ecc359b | |||
| 68713e7b77 | |||
| 899b3fc97b | |||
| a1490d0ac1 | |||
| b23f49ffce | |||
| 9a9e28e494 | |||
| 65ee5eb510 | |||
| 1f42d32e1a | |||
| f2ed8c1d6b | |||
| 308b216d1b | |||
| c713f5c8f5 | |||
| 6771eb25d1 | |||
| 91af50f99a | |||
| a559586e71 | |||
| 929520775d | |||
| 4eaf31bbf8 | |||
| 1607525539 | |||
| 31b5b4ca0c | |||
| 64c7fea2bc | |||
| f76fd364a2 | |||
| 0a1d6db9d1 | |||
| 7a750a127e | |||
| fce4a7b74c | |||
| fec7353703 | |||
| 40c90d5b3b | |||
| 9c58fec8d4 | |||
| 11343a30fd | |||
| 3693a7fee6 | |||
| a452bbe140 | |||
| 185c5e4bfb | |||
| bbe64aa970 | |||
| 513662d89a | |||
| 22f0d04f01 | |||
| d7c5161431 | |||
| 12cc09d0c9 | |||
| 5b12c36da5 | |||
| e970372a9f | |||
| 5d6c5da9d9 | |||
| ade2db3903 | |||
| 7f07279a4c | |||
| b25d4d57bd | |||
| 46edd8f9a4 | |||
| ebb95b5ee8 | |||
| dc4611a258 | |||
| 03d5b7a05b | |||
| a652fd879b | |||
| 326bf334e7 | |||
| 21a773f400 | |||
| d86003df55 | |||
| faff8e4ea2 | |||
| 6b85aa0b03 | |||
| 671678724b | |||
| e96a4818c4 | |||
| 0377e7ce19 | |||
| 6929886b3e | |||
| 721d70c8c7 | |||
| 27b45aee83 | |||
| 18ac76e683 | |||
| 3e1e23a524 | |||
| 534c7ab444 | |||
| c2c13ad318 | |||
| 3a21596d95 | |||
| ef30d389dc | |||
| bf8783fed7 | |||
| 4e042295d2 | |||
| 33d9c5db6c | |||
| cb182125f6 | |||
| ec627bdb82 | |||
| 4606e78570 | |||
| f00e968b8f | |||
| a87a015c03 | |||
| c559914ff7 | |||
| 436d95576e | |||
| 54e83391d0 | |||
| 3e0578f775 | |||
| 29a5abf4d6 | |||
| b54c67d6f1 | |||
| b49486cc23 | |||
| b95830b3d5 | |||
| 8e0c5cb9aa | |||
| 6ffb3bd30c | |||
| 2826444ffc | |||
| 56c3e9c46d | |||
| 0770e30034 | |||
| 04195c2957 | |||
| d18d74ac1c | |||
| 6391667c15 | |||
| 7f26445327 | |||
| 291d4bd8e5 | |||
| f2f3a7eb5f | |||
| e7609619d4 | |||
| 84bf9ecfaf | |||
| a2917b3ce8 | |||
| d094370209 | |||
| 1a6fbf782d | |||
| 3f67a23f8b | |||
| b8eb7e7b29 | |||
| 1d69916410 | |||
| 49fa103077 | |||
| e3a63db966 | |||
| ef4a1903ec | |||
| 5a3286dad1 | |||
| 058c6e4a85 | |||
| ea1d438e40 | |||
| a108eaf34b | |||
| da28c639b3 | |||
| 8ef68127d4 | |||
| f39d966a33 | |||
| f83e83de13 | |||
| abdafc9b3b | |||
| 8f93c50151 | |||
| fe7c505cc9 | |||
| 0d5afa4ff5 | |||
| 2ad716a4ad | |||
| cddf88730f | |||
| 3eb49aa24c | |||
| 45c987fd86 | |||
| 7bec05ec23 | |||
| 606b216cd0 | |||
| bb4f33559f | |||
| c7f3e53f28 | |||
| 0035e836d7 | |||
| e6da14f4a8 | |||
| f748f1849c | |||
| f8995b78f6 | |||
| a861020828 | |||
| dc94962900 | |||
| 4a40b87bba | |||
| 4853e2348d | |||
| e104f9f9b2 | |||
| 876381a7fb | |||
| 4f711b1bd3 | |||
| 89893c0945 | |||
| 7b7fe0ef46 | |||
| f66226c103 | |||
| d5c50ef27c | |||
| 2060ac18fd | |||
| c9c1cd8cbb | |||
| 5201dd5ad0 | |||
| 4c3c1e0b5f | |||
| c27651b7b7 | |||
| 06636d04ac | |||
| 6979e21628 | |||
| 310d266745 | |||
| 120e8d0686 | |||
| 186b56aed9 | |||
| c560fe13fe | |||
| 95e3ebd815 | |||
| dc2877d817 | |||
| 89d6af8fc9 | |||
| dcc9c9fa14 | |||
| a73906505c | |||
| f45290eb3a | |||
| 0fb6b2e54b | |||
| 9cc99c5c63 | |||
| 46801ce339 | |||
| 4345c58b45 | |||
| a2028623e7 | |||
| 8314ff3271 | |||
| 6a9408fe9b | |||
| b5e0a77401 | |||
| dffc6d9a9b | |||
| c9989108bd | |||
| 386c86bfa6 | |||
| 925cf16c50 | |||
| c561916b67 | |||
| d41a73b44b | |||
| 7995bdd4df | |||
| df95cc6af7 | |||
| c669e72189 | |||
| 46db5efef3 | |||
| 610418b42b | |||
| 508eb24fc3 | |||
| 6973272dd0 | |||
| d820c4367e | |||
| 96e1f85d6f | |||
| 6fa5ca5aa9 | |||
| 1d1f5641eb | |||
| 3f79df21d9 | |||
| ac1231ad54 | |||
| 8662d94ab6 | |||
| 3d23f3e3a2 | |||
| 6715d7dce6 | |||
| dad35f57d0 | |||
| f779e311ed | |||
| ce7c41e3ff | |||
| 74bb01d044 | |||
| 82f9698c0d | |||
| 6911883995 | |||
| 47c509faf6 | |||
| 8704942209 | |||
| e8cdc29bb5 | |||
| 191a7a6574 | |||
| ad5d21db5a | |||
| 569e939492 | |||
| 6a17c539d1 | |||
| f8a4f946e4 | |||
| 0715fa85ce | |||
| 1360051f0c | |||
| 42c4f9a6c6 | |||
| 11691d0128 | |||
| 26fe9c63a3 | |||
| 30cd033b42 | |||
| e21c0b4d62 | |||
| 916055d8bd | |||
| 683ca8af14 | |||
| 70151db91b | |||
| da3d4c36a9 | |||
| 1d01476523 | |||
| 75ceba1b08 | |||
| 493c37e7d5 | |||
| 6d686b284d | |||
| 60fcf6174e | |||
| 4141f451b7 | |||
| 7a9ee6e9e2 | |||
| cb28c31519 | |||
| 84f93f2ae6 | |||
| 30c09a7b54 | |||
| b3874a78b9 | |||
| 3e71965cd4 | |||
| 3df57f74ba | |||
| 7972cb8e1f | |||
| 0d74452c6c | |||
| f947f63e6d | |||
| fefa7ded5a | |||
| a46a4ad7c1 | |||
| e46f680651 | |||
| 93a20852f5 | |||
| 298bb64e66 | |||
| 0e5ac65f55 | |||
| cb6122f872 | |||
| 06500e0218 | |||
| 9ddf0b42e7 | |||
| 2056377f55 | |||
| 7065dabc94 | |||
| 9e2336a71e | |||
| d239c627f3 | |||
| 984b36d34e | |||
| c81bc2f536 | |||
| 87f7e65076 | |||
| 9985b68b6b | |||
| fb4b8b2789 | |||
| 3812ccc780 | |||
| 608a6c387a | |||
| 4875b37f70 | |||
| 2f3fba73de | |||
| 2ab1b9068f | |||
| b9613875ce | |||
| 5d2aea6b4f | |||
| 5824e18ed6 | |||
| 4f8648cbc9 | |||
| 01b021b2c3 | |||
| 331e8ce960 | |||
| a2cfe6fa51 | |||
| 8381fefb78 | |||
| d3b95d781a | |||
| 3a4a96f87a | |||
| 3d462c4be3 | |||
| 82b366cd9b | |||
| 897a4e5635 |
@@ -100,7 +100,6 @@ csharp_style_prefer_tuple_swap = true:warning
|
|||||||
csharp_style_prefer_utf8_string_literals = true:warning
|
csharp_style_prefer_utf8_string_literals = true:warning
|
||||||
csharp_style_throw_expression = true:warning
|
csharp_style_throw_expression = true:warning
|
||||||
csharp_style_unused_value_assignment_preference = discard_variable:warning
|
csharp_style_unused_value_assignment_preference = discard_variable:warning
|
||||||
csharp_style_unused_value_expression_statement_preference = discard_variable:warning
|
|
||||||
csharp_using_directive_placement = outside_namespace:warning
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:warning
|
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:warning
|
||||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:warning
|
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:warning
|
||||||
|
|||||||
@@ -3,13 +3,26 @@ description: 在提出问题前请先自行排除服务器端问题和升级到
|
|||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
### 报告 Bug 前请务必确认以下事项:
|
||||||
|
> ** **
|
||||||
|
> **✓ 已自行排除服务器端问题。**
|
||||||
|
> **✓ 已升级到最新客户端版本。**
|
||||||
|
> **✓ 已通过搜索确认没有人提出过相同问题。**
|
||||||
|
> **✓ 已确认自己的电脑系统环境是受支持的。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: "os-version"
|
id: "os-version"
|
||||||
attributes:
|
attributes:
|
||||||
label: "操作系统和版本"
|
label: "操作系统 / v2rayN / Core 版本"
|
||||||
description: "操作系统和版本"
|
description: "操作系统 / v2rayN / Core 版本"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: "expectation"
|
id: "expectation"
|
||||||
attributes:
|
attributes:
|
||||||
@@ -17,6 +30,7 @@ body:
|
|||||||
description: "描述你认为应该发生什么"
|
description: "描述你认为应该发生什么"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: "describe-the-bug"
|
id: "describe-the-bug"
|
||||||
attributes:
|
attributes:
|
||||||
@@ -24,22 +38,34 @@ body:
|
|||||||
description: "描述实际发生了什么"
|
description: "描述实际发生了什么"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: "reproduction-method"
|
id: "reproduction-method"
|
||||||
attributes:
|
attributes:
|
||||||
label: "复现方法"
|
label: "复现方法"
|
||||||
description: "在BUG出现前执行了哪些操作"
|
description: "在BUG出现前执行了哪些操作"
|
||||||
placeholder: 标序号
|
placeholder: "标序号"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: "log"
|
id: "gui-log"
|
||||||
attributes:
|
attributes:
|
||||||
label: "日志信息"
|
label: "软件日志"
|
||||||
description: "位置在软件当前目录下的guiLogs"
|
description: "位置在软件当前目录下的guiLogs"
|
||||||
placeholder: 在日志开始和结束位置粘贴冒号后的内容:```
|
placeholder: "在日志开始和结束位置粘贴冒号后的内容到这:"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: "core-log"
|
||||||
|
attributes:
|
||||||
|
label: "内核日志"
|
||||||
|
description: "位置在软件主界面的信息框内"
|
||||||
|
placeholder: "在信息框内鼠标右键复制全部信息粘贴在这:"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: "more"
|
id: "more"
|
||||||
attributes:
|
attributes:
|
||||||
@@ -47,6 +73,7 @@ body:
|
|||||||
description: "可选"
|
description: "可选"
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: "latest-version"
|
id: "latest-version"
|
||||||
attributes:
|
attributes:
|
||||||
@@ -55,6 +82,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: 是
|
- label: 是
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: "issues"
|
id: "issues"
|
||||||
attributes:
|
attributes:
|
||||||
@@ -63,3 +91,12 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: 是
|
- label: 是
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: "system-version"
|
||||||
|
attributes:
|
||||||
|
label: "我确认系统版本是受支持的"
|
||||||
|
description: "否则请切换后尝试"
|
||||||
|
options:
|
||||||
|
- label: 是
|
||||||
|
required: true
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Discussions / 讨论区
|
||||||
|
url: https://github.com/2dust/v2rayN/discussions
|
||||||
|
about: 使用问题或需要帮助请前往 Discussions。
|
||||||
|
- name: Wiki / 使用说明
|
||||||
|
url: https://github.com/2dust/v2rayN/wiki
|
||||||
|
about: 查看常见问题和使用文档。
|
||||||
@@ -6,6 +6,6 @@ updates:
|
|||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|
||||||
- package-ecosystem: "nuget"
|
- package-ecosystem: "nuget"
|
||||||
directory: "/"
|
directory: "/v2rayN"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
==============================================================================
|
||||||
|
THIRD-PARTY CA CERTIFICATE DATA ATTRIBUTION
|
||||||
|
==============================================================================
|
||||||
|
|
||||||
|
This component includes CA certificate data obtained from third-party sources.
|
||||||
|
|
||||||
|
1. Common CA Database (CCADB)
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
Website:
|
||||||
|
https://www.ccadb.org/
|
||||||
|
|
||||||
|
Data source:
|
||||||
|
https://ccadb.my.salesforce-sites.com/mozilla/IncludedRootsPEMTxt?TrustBitsInclude=Websites
|
||||||
|
|
||||||
|
License:
|
||||||
|
Community Data License Agreement – Permissive, Version 2.0 (CDLA-2.0 Permissive)
|
||||||
|
|
||||||
|
The names and trademarks of CCADB contributors may not be used to endorse or
|
||||||
|
promote products derived from this data without prior written permission.
|
||||||
|
|
||||||
|
License text:
|
||||||
|
https://cdla.dev/permissive-2-0/
|
||||||
|
|
||||||
|
|
||||||
|
2. Chromium Root Store
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
Source:
|
||||||
|
Chromium Project
|
||||||
|
|
||||||
|
Data source:
|
||||||
|
https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/root_store.certs?format=TEXT
|
||||||
|
|
||||||
|
License:
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright:
|
||||||
|
Copyright (c) The Chromium Authors
|
||||||
|
|
||||||
|
The Chromium Root Store data is distributed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
License text:
|
||||||
|
https://opensource.org/licenses/BSD-3-Clause
|
||||||
|
==============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.IO;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
|
||||||
|
|
||||||
|
var outputDir = args.Length > 0
|
||||||
|
? args[0]
|
||||||
|
: Path.Combine(Environment.CurrentDirectory, "v2rayN", "ServiceLib", "Sample");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(outputDir);
|
||||||
|
|
||||||
|
Console.WriteLine("=== Mozilla + Chrome Root Store PEM Converter ===\n");
|
||||||
|
Console.WriteLine($"Output directory: {outputDir}\n");
|
||||||
|
|
||||||
|
// Process Mozilla
|
||||||
|
await ProcessMozillaAsync(outputDir);
|
||||||
|
|
||||||
|
// Process Chrome
|
||||||
|
await ProcessChromeAsync(outputDir);
|
||||||
|
|
||||||
|
Console.WriteLine("\nAll done!");
|
||||||
|
|
||||||
|
async Task ProcessMozillaAsync(string outputDir)
|
||||||
|
{
|
||||||
|
const string certdataUrl = "https://ccadb.my.salesforce-sites.com/mozilla/IncludedRootsPEMTxt?TrustBitsInclude=Websites";
|
||||||
|
var outputFile = Path.Combine(outputDir, "mozilla_roots_pem");
|
||||||
|
|
||||||
|
Console.WriteLine("Downloading Mozilla certdata.txt...");
|
||||||
|
var content = await client.GetStringAsync(certdataUrl);
|
||||||
|
|
||||||
|
var pemMatches = Regex.Matches(content, @"(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)");
|
||||||
|
var pems = new List<string>();
|
||||||
|
foreach (Match m in pemMatches)
|
||||||
|
{
|
||||||
|
pems.Add(m.Groups[1].Value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(outputFile, string.Join("\n\n", pems));
|
||||||
|
Console.WriteLine($"Mozilla roots saved to: {outputFile} ({pems.Count} certificates)");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ProcessChromeAsync(string outputDir)
|
||||||
|
{
|
||||||
|
const string chromeCertsUrl = "https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/root_store.certs?format=TEXT";
|
||||||
|
var chromeOutput = Path.Combine(outputDir, "chrome_roots_pem");
|
||||||
|
|
||||||
|
Console.WriteLine("Downloading Chrome root_store.certs...");
|
||||||
|
var base64Content = await client.GetStringAsync(chromeCertsUrl);
|
||||||
|
var decoded = Convert.FromBase64String(base64Content.Replace("\n", ""));
|
||||||
|
|
||||||
|
var text = Encoding.UTF8.GetString(decoded);
|
||||||
|
|
||||||
|
var pemMatches = Regex.Matches(text, @"(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)");
|
||||||
|
var pems = new List<string>();
|
||||||
|
foreach (Match m in pemMatches)
|
||||||
|
{
|
||||||
|
pems.Add(m.Groups[1].Value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(chromeOutput, string.Join("\n\n", pems));
|
||||||
|
Console.WriteLine($"Chrome roots saved to: {chromeOutput} ({pems.Count} certificates)");
|
||||||
|
}
|
||||||
@@ -7,13 +7,25 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
public-key:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
uses: ./.github/workflows/pub-key.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
secrets:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
|
||||||
update:
|
update:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Trigger build windows
|
- name: Trigger build windows
|
||||||
if: github.event.inputs.release_tag != ''
|
if: inputs.release_tag != ''
|
||||||
run: |
|
run: |
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
@@ -22,12 +34,12 @@ jobs:
|
|||||||
-d "{
|
-d "{
|
||||||
\"ref\": \"master\",
|
\"ref\": \"master\",
|
||||||
\"inputs\": {
|
\"inputs\": {
|
||||||
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
|
\"release_tag\": \"${{ inputs.release_tag }}\"
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
|
|
||||||
- name: Trigger build linux
|
- name: Trigger build linux
|
||||||
if: github.event.inputs.release_tag != ''
|
if: inputs.release_tag != ''
|
||||||
run: |
|
run: |
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
@@ -36,12 +48,12 @@ jobs:
|
|||||||
-d "{
|
-d "{
|
||||||
\"ref\": \"master\",
|
\"ref\": \"master\",
|
||||||
\"inputs\": {
|
\"inputs\": {
|
||||||
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
|
\"release_tag\": \"${{ inputs.release_tag }}\"
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
|
|
||||||
- name: Trigger build osx
|
- name: Trigger build osx
|
||||||
if: github.event.inputs.release_tag != ''
|
if: inputs.release_tag != ''
|
||||||
run: |
|
run: |
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
@@ -50,12 +62,12 @@ jobs:
|
|||||||
-d "{
|
-d "{
|
||||||
\"ref\": \"master\",
|
\"ref\": \"master\",
|
||||||
\"inputs\": {
|
\"inputs\": {
|
||||||
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
|
\"release_tag\": \"${{ inputs.release_tag }}\"
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
|
|
||||||
- name: Trigger build windows desktop
|
- name: Trigger build windows desktop
|
||||||
if: github.event.inputs.release_tag != ''
|
if: inputs.release_tag != ''
|
||||||
run: |
|
run: |
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
@@ -64,6 +76,20 @@ jobs:
|
|||||||
-d "{
|
-d "{
|
||||||
\"ref\": \"master\",
|
\"ref\": \"master\",
|
||||||
\"inputs\": {
|
\"inputs\": {
|
||||||
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
|
\"release_tag\": \"${{ inputs.release_tag }}\"
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
|
||||||
|
- name: Trigger build windows x86
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
run: |
|
||||||
|
curl -X POST \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-windows-x86.yml/dispatches \
|
||||||
|
-d "{
|
||||||
|
\"ref\": \"master\",
|
||||||
|
\"inputs\": {
|
||||||
|
\"release_tag\": \"${{ inputs.release_tag }}\"
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@@ -10,92 +10,536 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
env:
|
permissions:
|
||||||
OutputArch: "linux-64"
|
contents: write
|
||||||
OutputArchArm: "linux-arm64"
|
actions: read
|
||||||
OutputPath64: "${{ github.workspace }}/v2rayN/Release/linux-64"
|
|
||||||
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/linux-arm64"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
uses: ./.github/workflows/build.yml
|
||||||
matrix:
|
with:
|
||||||
configuration: [Release]
|
target: linux
|
||||||
|
|
||||||
runs-on: ubuntu-22.04
|
release-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: build
|
||||||
|
uses: ./.github/workflows/package-zip.yml
|
||||||
|
with:
|
||||||
|
target: linux
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
deb:
|
||||||
|
name: build and release deb x64 & arm64
|
||||||
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
container: debian:13
|
||||||
|
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Prepare tools (Debian)
|
||||||
uses: actions/checkout@v4.2.2
|
shell: bash
|
||||||
with:
|
|
||||||
submodules: 'recursive'
|
|
||||||
fetch-depth: '0'
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: actions/setup-dotnet@v4.3.1
|
|
||||||
with:
|
|
||||||
dotnet-version: '8.0.x'
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
run: |
|
||||||
cd v2rayN
|
set -euo pipefail
|
||||||
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r linux-x64 --self-contained=true -o $OutputPath64
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r linux-arm64 --self-contained=true -o $OutputPathArm64
|
apt-get update
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r linux-x64 --self-contained=true -p:PublishTrimmed=true -o $OutputPath64
|
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates desktop-file-utils xdg-utils fakeroot dpkg-dev libc6 libgcc-s1 libstdc++6 zlib1g libicu-dev libssl-dev
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r linux-arm64 --self-contained=true -p:PublishTrimmed=true -o $OutputPathArm64
|
- name: Checkout repo (for scripts)
|
||||||
|
uses: actions/checkout@v7
|
||||||
- name: Upload build artifacts
|
with: { submodules: 'recursive', fetch-depth: '0' }
|
||||||
uses: actions/upload-artifact@v4.6.2
|
- name: Ensure script permissions
|
||||||
with:
|
run: chmod 755 package-debian.sh
|
||||||
name: v2rayN-linux
|
- name: Package DEB (Debian-family)
|
||||||
path: |
|
run: ./package-debian.sh "${RELEASE_TAG}" --arch all
|
||||||
${{ github.workspace }}/v2rayN/Release/linux*
|
- name: Collect DEBs into workspace
|
||||||
|
|
||||||
# release debian package
|
|
||||||
- name: Package debian
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
run: |
|
run: |
|
||||||
chmod 755 package-debian.sh
|
mkdir -p "$GITHUB_WORKSPACE/dist/deb"
|
||||||
./package-debian.sh $OutputArch $OutputPath64 ${{ github.event.inputs.release_tag }}
|
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb/" || true
|
||||||
./package-debian.sh $OutputArchArm $OutputPathArm64 ${{ github.event.inputs.release_tag }}
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_amd64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-64.deb" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_arm64.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-arm64.deb" \; || true
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/deb" || true
|
||||||
|
- name: Upload DEB artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-deb, path: "dist/deb/**/*.deb", if-no-files-found: error }
|
||||||
|
|
||||||
- name: Upload deb to release
|
rpm:
|
||||||
uses: svenstaro/upload-release-action@v2
|
name: build and release rpm x64 & arm64
|
||||||
if: github.event.inputs.release_tag != ''
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
with:
|
runs-on: ubuntu-26.04
|
||||||
file: ${{ github.workspace }}/v2rayN*.deb
|
container: registry.access.redhat.com/ubi10/ubi
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
||||||
file_glob: true
|
steps:
|
||||||
prerelease: true
|
- name: Prepare tools (Red Hat)
|
||||||
|
shell: bash
|
||||||
- name: Package AppImage
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
run: |
|
run: |
|
||||||
chmod a+x package-appimage.sh
|
set -euo pipefail
|
||||||
./package-appimage.sh
|
. /etc/os-release
|
||||||
|
EL_MAJOR="${VERSION_ID%%.*}"
|
||||||
|
echo "EL_MAJOR=${EL_MAJOR}"
|
||||||
|
dnf -y makecache || true
|
||||||
|
command -v curl >/dev/null || dnf -y install curl ca-certificates
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in x86_64|aarch64) ;; *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac
|
||||||
|
|
||||||
- name: Upload AppImage to release
|
install_epel_from_dir() {
|
||||||
uses: svenstaro/upload-release-action@v2
|
local base="$1" rpm
|
||||||
if: github.event.inputs.release_tag != ''
|
echo "Try: $base"
|
||||||
with:
|
rpm="$({ curl -fsSL "$base/Packages/" 2>/dev/null; curl -fsSL "$base/Packages/e/" 2>/dev/null | sed 's|href="|href="e/|'; } |
|
||||||
file: ${{ github.workspace }}/v2rayN*.AppImage
|
sed -n 's/.*href="\([^"]*epel-release-[^"]*\.noarch\.rpm\)".*/\1/p' | sort -V | tail -n1)" || true
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
[[ -n "$rpm" ]] || return 1
|
||||||
file_glob: true
|
dnf -y install "$base/Packages/$rpm"
|
||||||
prerelease: true
|
}
|
||||||
|
|
||||||
# release zip archive
|
FEDORA="https://dl.fedoraproject.org/pub/epel/epel-release-latest-${EL_MAJOR}.noarch.rpm"
|
||||||
- name: Package release zip archive
|
ROCKY="https://dl.rockylinux.org/pub/rocky/${EL_MAJOR}/extras/${ARCH}/os"
|
||||||
if: github.event.inputs.release_tag != ''
|
ALMA="https://repo.almalinux.org/almalinux/${EL_MAJOR}/extras/${ARCH}/os"
|
||||||
|
|
||||||
|
echo "Try Fedora: $FEDORA"
|
||||||
|
curl -fsSLI "$FEDORA" >/dev/null && dnf -y install "$FEDORA" ||
|
||||||
|
install_epel_from_dir "$ROCKY" ||
|
||||||
|
install_epel_from_dir "$ALMA" ||
|
||||||
|
{ echo "EPEL bootstrap failed (Fedora/Rocky/Alma)"; exit 1; }
|
||||||
|
|
||||||
|
dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which
|
||||||
|
dnf repolist | grep -i epel || true
|
||||||
|
- name: Checkout repo (for scripts)
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with: { submodules: 'recursive', fetch-depth: '0' }
|
||||||
|
- name: Ensure script permissions
|
||||||
|
run: chmod 755 package-rhel.sh
|
||||||
|
- name: Package RPM (RHEL-family)
|
||||||
|
run: ./package-rhel.sh "${RELEASE_TAG}" --arch all
|
||||||
|
- name: Collect RPMs into workspace
|
||||||
run: |
|
run: |
|
||||||
chmod 755 package-release-zip.sh
|
mkdir -p "$GITHUB_WORKSPACE/dist/rpm"
|
||||||
./package-release-zip.sh $OutputArch $OutputPath64
|
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm/" || true
|
||||||
./package-release-zip.sh $OutputArchArm $OutputPathArm64
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.x86_64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-64.rpm" \; || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.aarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-arm64.rpm" \; || true
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/rpm" || true
|
||||||
|
- name: Upload RPM artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-rpm, path: "dist/rpm/**/*.rpm", if-no-files-found: error }
|
||||||
|
|
||||||
- name: Upload zip archive to release
|
rpm-riscv64:
|
||||||
uses: svenstaro/upload-release-action@v2
|
name: build and release rpm riscv64
|
||||||
if: github.event.inputs.release_tag != ''
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
with:
|
runs-on: ubuntu-24.04-riscv
|
||||||
file: ${{ github.workspace }}/v2rayN*.zip
|
container: rockylinux/rockylinux:10
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
||||||
file_glob: true
|
steps:
|
||||||
prerelease: true
|
- name: Prepare tools (Red Hat)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
dnf -y makecache
|
||||||
|
dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which jq
|
||||||
|
- name: Checkout repo (for scripts)
|
||||||
|
shell: bash
|
||||||
|
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf ./*
|
||||||
|
git init .
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
git submodule update --init --recursive
|
||||||
|
- name: Ensure script permissions
|
||||||
|
run: chmod 755 package-rhel-riscv.sh
|
||||||
|
- name: Package RPM (RHEL-family)
|
||||||
|
run: ./package-rhel-riscv.sh "${RELEASE_TAG}"
|
||||||
|
- name: Collect RPMs into workspace
|
||||||
|
run: |
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-riscv64"
|
||||||
|
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm-riscv64/" || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm-riscv64" -name "*.riscv64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm-riscv64/v2rayN-linux-rhel-riscv64.rpm" \; || true
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/rpm-riscv64" || true
|
||||||
|
- name: Upload RPM artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-rpm-riscv64, path: "dist/rpm-riscv64/**/*.rpm", if-no-files-found: error }
|
||||||
|
|
||||||
|
deb-riscv64:
|
||||||
|
name: build and release deb riscv64
|
||||||
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
|
runs-on: ubuntu-24.04-riscv
|
||||||
|
container: debian:13
|
||||||
|
env: { RELEASE_TAG: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}" }
|
||||||
|
steps:
|
||||||
|
- name: Prepare tools (Debian)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates desktop-file-utils xdg-utils fakeroot dpkg-dev gcc make libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1
|
||||||
|
- name: Checkout repo (for scripts)
|
||||||
|
shell: bash
|
||||||
|
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf ./*
|
||||||
|
git init .
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
git submodule update --init --recursive
|
||||||
|
- name: Ensure script permissions
|
||||||
|
run: chmod 755 package-debian-riscv.sh
|
||||||
|
- name: Package DEB (Debian-family)
|
||||||
|
run: ./package-debian-riscv.sh "${RELEASE_TAG}"
|
||||||
|
- name: Collect DEBs into workspace
|
||||||
|
run: |
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/dist/deb-riscv64"
|
||||||
|
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb-riscv64/" || true
|
||||||
|
find "$GITHUB_WORKSPACE/dist/deb-riscv64" -name "*.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb-riscv64/v2rayN-linux-riscv64.deb" \; || true
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/deb-riscv64" || true
|
||||||
|
- name: Upload DEB artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-deb-riscv64, path: "dist/deb-riscv64/**/*.deb", if-no-files-found: error }
|
||||||
|
|
||||||
|
deb-loong64:
|
||||||
|
name: build and release deb loong64
|
||||||
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
|
runs-on: ubuntu-26.04-arm
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
|
||||||
|
QCOW2_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/debian13-loong64.qcow2
|
||||||
|
EFI_CODE_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-code.fd
|
||||||
|
EFI_VARS_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-vars.fd
|
||||||
|
QCOW2_IMAGE: debian13-loong64.qcow2
|
||||||
|
EFI_CODE: edk2-loongarch64-code.fd
|
||||||
|
EFI_VARS: edk2-loongarch64-vars.fd
|
||||||
|
QEMU_VERSION: 10.2.4
|
||||||
|
steps:
|
||||||
|
- name: Prepare host tools
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y git rsync qemu-utils qemu-system-common expect wget curl ca-certificates libfdt1 libglib2.0-0 libpixman-1-0 libslirp0 virtiofsd
|
||||||
|
sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||||
|
virtiofsd --version || virtiofsd --help | head
|
||||||
|
- name: Host clone repo
|
||||||
|
shell: bash
|
||||||
|
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf repo
|
||||||
|
git clone --no-single-branch --tags "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" repo
|
||||||
|
- name: Download QEMU prebuild
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
wget -O qemu-linux-arm64-v8.2.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-arm64-v8.2.tar.gz"
|
||||||
|
tar -xzf qemu-linux-arm64-v8.2.tar.gz
|
||||||
|
mkdir -p "$HOME/qemu-install"
|
||||||
|
rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/"
|
||||||
|
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
|
||||||
|
- name: Download loong64 qcow2 and EFI firmware
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
wget -O "$QCOW2_IMAGE" "$QCOW2_URL"
|
||||||
|
wget -O "$EFI_CODE" "$EFI_CODE_URL"
|
||||||
|
wget -O "$EFI_VARS" "$EFI_VARS_URL"
|
||||||
|
qemu-img info "$QCOW2_IMAGE"
|
||||||
|
- name: Build loong64 DEB in VM through serial console
|
||||||
|
shell: bash
|
||||||
|
timeout-minutes: 180
|
||||||
|
env: { RELEASE_TAG: "${{ env.RELEASE_TAG }}" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/dist/deb-loong64"
|
||||||
|
|
||||||
|
export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-deb-loong64.sock"
|
||||||
|
rm -f "$VIRTIOFSD_SOCKET"
|
||||||
|
|
||||||
|
sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto &
|
||||||
|
|
||||||
|
VIRTIOFSD_PID=$!
|
||||||
|
trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT
|
||||||
|
|
||||||
|
for i in {1..100}; do
|
||||||
|
[[ -S "$VIRTIOFSD_SOCKET" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; }
|
||||||
|
sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true
|
||||||
|
sudo chmod 660 "$VIRTIOFSD_SOCKET" || true
|
||||||
|
expect <<'EOF'
|
||||||
|
log_user 1
|
||||||
|
set timeout -1
|
||||||
|
set release_tag $env(RELEASE_TAG)
|
||||||
|
set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64"
|
||||||
|
set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu"
|
||||||
|
set virtiofs_socket $env(VIRTIOFSD_SOCKET)
|
||||||
|
set qcow2 $env(QCOW2_IMAGE)
|
||||||
|
set efi_code $env(EFI_CODE)
|
||||||
|
set efi_vars $env(EFI_VARS)
|
||||||
|
proc wait_prompt {} {
|
||||||
|
expect {
|
||||||
|
-re "__CI_PROMPT__# " {}
|
||||||
|
timeout { exit 1 }
|
||||||
|
eof { exit 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc run_cmd {cmd} {
|
||||||
|
send -- "$cmd\r"
|
||||||
|
wait_prompt
|
||||||
|
}
|
||||||
|
spawn $qemu_bin \
|
||||||
|
-L $qemu_rom_dir \
|
||||||
|
-machine virt \
|
||||||
|
-accel tcg,thread=multi,tb-size=2048 \
|
||||||
|
-cpu la464 \
|
||||||
|
-m 9216 \
|
||||||
|
-object memory-backend-memfd,id=mem0,size=9216M,share=on \
|
||||||
|
-numa node,memdev=mem0 \
|
||||||
|
-smp 4 \
|
||||||
|
-drive if=pflash,format=raw,unit=0,file=$efi_code,readonly=on \
|
||||||
|
-drive if=pflash,format=raw,unit=1,file=$efi_vars \
|
||||||
|
-device virtio-blk-pci,drive=hd0,bootindex=1 \
|
||||||
|
-drive if=none,media=disk,id=hd0,file=$qcow2,format=qcow2 \
|
||||||
|
-netdev user,id=net0 \
|
||||||
|
-device virtio-net-pci-non-transitional,netdev=net0 \
|
||||||
|
-chardev socket,id=charfs0,path=$virtiofs_socket \
|
||||||
|
-device vhost-user-fs-pci,chardev=charfs0,tag=workspace \
|
||||||
|
-display none \
|
||||||
|
-serial stdio \
|
||||||
|
-monitor none
|
||||||
|
expect -re "login:|debian login:"
|
||||||
|
send -- "root\r"
|
||||||
|
expect -re "Password:|密码:|密码:"
|
||||||
|
send -- "password\r"
|
||||||
|
expect {
|
||||||
|
-re "# " {}
|
||||||
|
timeout { exit 1 }
|
||||||
|
eof { exit 1 }
|
||||||
|
}
|
||||||
|
send -- "export TERM=dumb; export PS1='__CI_PROMPT__# '\r"
|
||||||
|
wait_prompt
|
||||||
|
run_cmd "mkdir -p /workspace"
|
||||||
|
run_cmd "mount -t virtiofs workspace /workspace"
|
||||||
|
run_cmd "IFACE=\$(ip -o link show | awk -F': ' '\$2 != \"lo\" {print \$2; exit}') ; ip link set \$IFACE up || true"
|
||||||
|
run_cmd "dhclient \$IFACE || true"
|
||||||
|
run_cmd "printf 'nameserver 10.0.2.3\nnameserver 1.1.1.1\n' >/etc/resolv.conf"
|
||||||
|
run_cmd "apt-get update || apt-get update || apt-get update"
|
||||||
|
run_cmd "DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates desktop-file-utils xdg-utils fakeroot dpkg-dev gcc make libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 fuse3"
|
||||||
|
run_cmd "rm -rf /root/v2rayN-src"
|
||||||
|
run_cmd "rsync -a --info=progress2 /workspace/repo/ /root/v2rayN-src/"
|
||||||
|
run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src"
|
||||||
|
run_cmd "cd /root/v2rayN-src && chmod 755 package-debian-loong.sh"
|
||||||
|
send -- "cd /root/v2rayN-src; cat >/tmp/run-loong-build.sh <<'SCRIPT'\nset +e\nset -o pipefail\nbash -x ./package-debian-loong.sh \"\$RELEASE_TAG\" 2>&1 | tee /tmp/build.log\nrc=\$?\nmkdir -p /workspace/dist/deb-loong64\ncp -av /root/debbuild/*.deb /workspace/dist/deb-loong64/ 2>/dev/null || true\necho __BUILD_DONE__\$rc\nSCRIPT\nRELEASE_TAG=\"$release_tag\" bash /tmp/run-loong-build.sh\r"
|
||||||
|
expect {
|
||||||
|
-re "__BUILD_DONE__0" { send -- "poweroff\r" }
|
||||||
|
default { exit 1 }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
- name: Collect DEBs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/dist/deb-loong64"
|
||||||
|
find "$GITHUB_WORKSPACE/dist/deb-loong64" -name "*.deb" -exec mv {} "$GITHUB_WORKSPACE/dist/deb-loong64/v2rayN-linux-loong64.deb" \; || true
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/deb-loong64"
|
||||||
|
- name: Upload DEB artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-deb-loong64, path: "dist/deb-loong64/**/*.deb", if-no-files-found: error }
|
||||||
|
|
||||||
|
rpm-loong64:
|
||||||
|
name: build and release rpm loong64
|
||||||
|
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
|
||||||
|
runs-on: ubuntu-26.04-arm
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
|
||||||
|
QCOW2_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/fedora43-loong64.qcow2
|
||||||
|
EFI_CODE_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/edk2-loongarch64-code.fd
|
||||||
|
EFI_VARS_URL: https://github.com/xujiegb/fedora-loong64-qcow2/releases/download/43/edk2-loongarch64-vars.fd
|
||||||
|
QCOW2_IMAGE: fedora43-loong64.qcow2
|
||||||
|
EFI_CODE: edk2-loongarch64-code.fd
|
||||||
|
EFI_VARS: edk2-loongarch64-vars.fd
|
||||||
|
QEMU_VERSION: 10.2.4
|
||||||
|
steps:
|
||||||
|
- name: Prepare host tools
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y git rsync qemu-utils qemu-system-common expect wget curl ca-certificates libfdt1 libglib2.0-0 libpixman-1-0 libslirp0 virtiofsd
|
||||||
|
sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||||
|
virtiofsd --version || virtiofsd --help | head
|
||||||
|
- name: Host clone repo
|
||||||
|
shell: bash
|
||||||
|
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf repo
|
||||||
|
git clone --no-single-branch --tags "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" repo
|
||||||
|
- name: Download QEMU prebuild
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
wget -O qemu-linux-arm64-v8.2.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-arm64-v8.2.tar.gz"
|
||||||
|
tar -xzf qemu-linux-arm64-v8.2.tar.gz
|
||||||
|
mkdir -p "$HOME/qemu-install"
|
||||||
|
rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/"
|
||||||
|
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
|
||||||
|
- name: Download loong64 qcow2 and EFI firmware
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
wget -O "$QCOW2_IMAGE" "$QCOW2_URL"
|
||||||
|
wget -O "$EFI_CODE" "$EFI_CODE_URL"
|
||||||
|
wget -O "$EFI_VARS" "$EFI_VARS_URL"
|
||||||
|
qemu-img info "$QCOW2_IMAGE"
|
||||||
|
- name: Build loong64 RPM in VM through serial console
|
||||||
|
shell: bash
|
||||||
|
timeout-minutes: 180
|
||||||
|
env: { RELEASE_TAG: "${{ env.RELEASE_TAG }}" }
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-loong64"
|
||||||
|
|
||||||
|
export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-rpm-loong64.sock"
|
||||||
|
rm -f "$VIRTIOFSD_SOCKET"
|
||||||
|
|
||||||
|
sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto &
|
||||||
|
|
||||||
|
VIRTIOFSD_PID=$!
|
||||||
|
trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT
|
||||||
|
|
||||||
|
for i in {1..100}; do
|
||||||
|
[[ -S "$VIRTIOFSD_SOCKET" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; }
|
||||||
|
sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true
|
||||||
|
sudo chmod 660 "$VIRTIOFSD_SOCKET" || true
|
||||||
|
expect <<'EOF'
|
||||||
|
log_user 1
|
||||||
|
set timeout -1
|
||||||
|
set release_tag $env(RELEASE_TAG)
|
||||||
|
set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64"
|
||||||
|
set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu"
|
||||||
|
set virtiofs_socket $env(VIRTIOFSD_SOCKET)
|
||||||
|
set qcow2 $env(QCOW2_IMAGE)
|
||||||
|
set efi_code $env(EFI_CODE)
|
||||||
|
set efi_vars $env(EFI_VARS)
|
||||||
|
proc wait_prompt {} {
|
||||||
|
expect {
|
||||||
|
-re "__CI_PROMPT__# " {}
|
||||||
|
timeout { exit 1 }
|
||||||
|
eof { exit 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc run_cmd {cmd} {
|
||||||
|
send -- "$cmd\r"
|
||||||
|
wait_prompt
|
||||||
|
}
|
||||||
|
spawn $qemu_bin \
|
||||||
|
-L $qemu_rom_dir \
|
||||||
|
-machine virt \
|
||||||
|
-accel tcg,thread=multi,tb-size=2048 \
|
||||||
|
-cpu la464 \
|
||||||
|
-m 9216 \
|
||||||
|
-object memory-backend-memfd,id=mem0,size=9216M,share=on \
|
||||||
|
-numa node,memdev=mem0 \
|
||||||
|
-smp 4 \
|
||||||
|
-drive if=pflash,format=raw,unit=0,file=$efi_code,readonly=on \
|
||||||
|
-drive if=pflash,format=raw,unit=1,file=$efi_vars \
|
||||||
|
-device virtio-blk-pci,drive=hd0,bootindex=1 \
|
||||||
|
-drive if=none,media=disk,id=hd0,file=$qcow2,format=qcow2 \
|
||||||
|
-netdev user,id=net0 \
|
||||||
|
-device virtio-net-pci-non-transitional,netdev=net0 \
|
||||||
|
-chardev socket,id=charfs0,path=$virtiofs_socket \
|
||||||
|
-device vhost-user-fs-pci,chardev=charfs0,tag=workspace \
|
||||||
|
-display none \
|
||||||
|
-serial stdio \
|
||||||
|
-monitor none
|
||||||
|
expect -re "login:|fedora login:"
|
||||||
|
send -- "root\r"
|
||||||
|
expect -re "Password:|密码:|密码:"
|
||||||
|
send -- "password\r"
|
||||||
|
expect {
|
||||||
|
-re "# " {}
|
||||||
|
timeout { exit 1 }
|
||||||
|
eof { exit 1 }
|
||||||
|
}
|
||||||
|
send -- "export TERM=dumb; export PS1='__CI_PROMPT__# '\r"
|
||||||
|
wait_prompt
|
||||||
|
run_cmd "mkdir -p /workspace"
|
||||||
|
run_cmd "mount -t virtiofs workspace /workspace"
|
||||||
|
run_cmd "IFACE=\$(ip -o link show | awk -F': ' '\$2 != \"lo\" {print \$2; exit}') ; ip link set \$IFACE up || true"
|
||||||
|
run_cmd "dhclient \$IFACE || true"
|
||||||
|
run_cmd "printf 'nameserver 10.0.2.3\nnameserver 1.1.1.1\n' >/etc/resolv.conf"
|
||||||
|
run_cmd "dnf -y makecache || dnf -y makecache || dnf -y makecache"
|
||||||
|
run_cmd "dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core rsync findutils tar gzip unzip which curl jq file ca-certificates fuse3 --nogpgcheck"
|
||||||
|
run_cmd "rm -rf /root/v2rayN-src"
|
||||||
|
run_cmd "rsync -a --info=progress2 /workspace/repo/ /root/v2rayN-src/"
|
||||||
|
run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src"
|
||||||
|
run_cmd "cd /root/v2rayN-src && chmod 755 package-rhel-loong.sh"
|
||||||
|
send -- "cd /root/v2rayN-src; cat >/tmp/run-loong-rpm-build.sh <<'SCRIPT'\nset +e\nset -o pipefail\nbash -x ./package-rhel-loong.sh \"\$RELEASE_TAG\" 2>&1 | tee /tmp/build.log\nrc=\$?\nmkdir -p /workspace/dist/rpm-loong64\nfind /root/rpmbuild/RPMS -name '*.rpm' -exec cp -av {} /workspace/dist/rpm-loong64/ \\; 2>/dev/null || true\necho __BUILD_DONE__\$rc\nSCRIPT\nRELEASE_TAG=\"$release_tag\" bash /tmp/run-loong-rpm-build.sh\r"
|
||||||
|
expect {
|
||||||
|
-re "__BUILD_DONE__0" { send -- "poweroff\r" }
|
||||||
|
default { exit 1 }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
- name: Collect RPMs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-loong64"
|
||||||
|
find "$GITHUB_WORKSPACE/dist/rpm-loong64" -name "*.loongarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm-loong64/v2rayN-linux-rhel-loong64.rpm" \; || true
|
||||||
|
echo "==== Dist tree ===="
|
||||||
|
ls -R "$GITHUB_WORKSPACE/dist/rpm-loong64"
|
||||||
|
- name: Upload RPM artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with: { name: release-rpm-loong64, path: "dist/rpm-loong64/**/*.rpm", if-no-files-found: error }
|
||||||
|
|
||||||
|
release-deb:
|
||||||
|
name: sign and upload deb x64 arm64
|
||||||
|
needs: deb
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-deb, file_pattern: "*.deb" }
|
||||||
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
|
release-rpm:
|
||||||
|
name: sign and upload rpm x64 arm64
|
||||||
|
needs: rpm
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-rpm, file_pattern: "*.rpm" }
|
||||||
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
|
release-rpm-riscv64:
|
||||||
|
name: sign and upload rpm riscv64
|
||||||
|
needs: rpm-riscv64
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-rpm-riscv64, file_pattern: "*.rpm" }
|
||||||
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
|
release-deb-riscv64:
|
||||||
|
name: sign and upload deb riscv64
|
||||||
|
needs: deb-riscv64
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-deb-riscv64, file_pattern: "*.deb" }
|
||||||
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
|
release-deb-loong64:
|
||||||
|
name: sign and upload deb loong64
|
||||||
|
needs: deb-loong64
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-deb-loong64, file_pattern: "*.deb" }
|
||||||
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|
||||||
|
release-rpm-loong64:
|
||||||
|
name: sign and upload rpm loong64
|
||||||
|
needs: rpm-loong64
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with: { release_tag: "${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}", artifact_name: release-rpm-loong64, file_pattern: "*.rpm" }
|
||||||
|
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
|
||||||
|
|||||||
@@ -10,78 +10,78 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
env:
|
permissions:
|
||||||
OutputArch: "macos-64"
|
contents: write
|
||||||
OutputArchArm: "macos-arm64"
|
actions: read
|
||||||
OutputPath64: "${{ github.workspace }}/v2rayN/Release/macos-64"
|
|
||||||
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/macos-arm64"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
with:
|
||||||
|
target: macos
|
||||||
|
|
||||||
|
release-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: build
|
||||||
|
uses: ./.github/workflows/package-zip.yml
|
||||||
|
with:
|
||||||
|
target: macos
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
dmg:
|
||||||
|
name: package and release macOS dmg
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: build
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
configuration: [Release]
|
arch: [ x64, arm64 ]
|
||||||
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
Arch: |-
|
||||||
|
${{
|
||||||
|
case(
|
||||||
|
matrix.arch == 'x64', '64',
|
||||||
|
matrix.arch
|
||||||
|
)
|
||||||
|
}}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Restore build artifacts
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
submodules: 'recursive'
|
name: ${{ matrix.arch }}
|
||||||
fetch-depth: '0'
|
path: v2rayN-macos-${{ env.Arch }}
|
||||||
|
|
||||||
- name: Setup
|
- name: Setup create-dmg
|
||||||
uses: actions/setup-dotnet@v4.3.1
|
run: brew install create-dmg
|
||||||
|
|
||||||
|
- name: Ensure script permissions
|
||||||
|
run: chmod 755 package-osx.sh
|
||||||
|
|
||||||
|
- name: Package dmg
|
||||||
|
run: ./package-osx.sh macos-$Arch v2rayN-macos-$Arch ${{ inputs.release_tag }}
|
||||||
|
|
||||||
|
- name: Upload dmg artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
name: release-dmg-${{ matrix.arch }}
|
||||||
|
path: v2rayN*.dmg
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Build
|
release-dmg:
|
||||||
run: |
|
name: sign and upload macOS dmg
|
||||||
cd v2rayN
|
if: inputs.release_tag != ''
|
||||||
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r osx-x64 --self-contained=true -o $OutputPath64
|
needs: dmg
|
||||||
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r osx-arm64 --self-contained=true -o $OutputPathArm64
|
strategy:
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r osx-x64 --self-contained=true -p:PublishTrimmed=true -o $OutputPath64
|
matrix:
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r osx-arm64 --self-contained=true -p:PublishTrimmed=true -o $OutputPathArm64
|
arch: [ x64, arm64 ]
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
- name: Upload build artifacts
|
with:
|
||||||
uses: actions/upload-artifact@v4.6.2
|
release_tag: ${{ inputs.release_tag }}
|
||||||
with:
|
artifact_name: release-dmg-${{ matrix.arch }}
|
||||||
name: v2rayN-macos
|
file_pattern: '*.dmg'
|
||||||
path: |
|
secrets:
|
||||||
${{ github.workspace }}/v2rayN/Release/macos*
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
|
||||||
# release osx package
|
|
||||||
- name: Package osx
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
run: |
|
|
||||||
brew install create-dmg
|
|
||||||
chmod 755 package-osx.sh
|
|
||||||
./package-osx.sh $OutputArch $OutputPath64 ${{ github.event.inputs.release_tag }}
|
|
||||||
./package-osx.sh $OutputArchArm $OutputPathArm64 ${{ github.event.inputs.release_tag }}
|
|
||||||
|
|
||||||
- name: Upload dmg to release
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
with:
|
|
||||||
file: ${{ github.workspace }}/v2rayN*.dmg
|
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
|
||||||
file_glob: true
|
|
||||||
prerelease: true
|
|
||||||
|
|
||||||
# release zip archive
|
|
||||||
- name: Package release zip archive
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
run: |
|
|
||||||
chmod 755 package-release-zip.sh
|
|
||||||
./package-release-zip.sh $OutputArch $OutputPath64
|
|
||||||
./package-release-zip.sh $OutputArchArm $OutputPathArm64
|
|
||||||
|
|
||||||
- name: Upload zip archive to release
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
with:
|
|
||||||
file: ${{ github.workspace }}/v2rayN*.zip
|
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
|
||||||
file_glob: true
|
|
||||||
prerelease: true
|
|
||||||
|
|||||||
@@ -10,62 +10,21 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
env:
|
permissions:
|
||||||
OutputArch: "windows-64"
|
contents: write
|
||||||
OutputArchArm: "windows-arm64"
|
actions: read
|
||||||
OutputPath64: "${{ github.workspace }}/v2rayN/Release/windows-64"
|
|
||||||
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/windows-arm64"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
uses: ./.github/workflows/build.yml
|
||||||
matrix:
|
with:
|
||||||
configuration: [Release]
|
target: windows
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
release-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
steps:
|
needs: build
|
||||||
- name: Checkout
|
uses: ./.github/workflows/package-zip.yml
|
||||||
uses: actions/checkout@v4.2.2
|
with:
|
||||||
with:
|
target: windows-desktop
|
||||||
submodules: 'recursive'
|
release_tag: ${{ inputs.release_tag }}
|
||||||
fetch-depth: '0'
|
secrets: inherit
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: actions/setup-dotnet@v4.3.1
|
|
||||||
with:
|
|
||||||
dotnet-version: '8.0.x'
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
cd v2rayN
|
|
||||||
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -o $OutputPath64
|
|
||||||
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r win-arm64 --self-contained=true -p:EnableWindowsTargeting=true -o $OutputPathArm64
|
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -p:PublishTrimmed=true -o $OutputPath64
|
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-arm64 --self-contained=true -p:EnableWindowsTargeting=true -p:PublishTrimmed=true -o $OutputPathArm64
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v4.6.2
|
|
||||||
with:
|
|
||||||
name: v2rayN-windows-desktop
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/v2rayN/Release/windows*
|
|
||||||
|
|
||||||
# release zip archive
|
|
||||||
- name: Package release zip archive
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
run: |
|
|
||||||
chmod 755 package-release-zip.sh
|
|
||||||
./package-release-zip.sh $OutputArch $OutputPath64
|
|
||||||
mv "v2rayN-${OutputArch}.zip" "v2rayN-${OutputArch}-desktop.zip"
|
|
||||||
./package-release-zip.sh $OutputArchArm $OutputPathArm64
|
|
||||||
mv "v2rayN-${OutputArchArm}.zip" "v2rayN-${OutputArchArm}-desktop.zip"
|
|
||||||
|
|
||||||
- name: Upload zip archive to release
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
with:
|
|
||||||
file: ${{ github.workspace }}/v2rayN*.zip
|
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
|
||||||
file_glob: true
|
|
||||||
prerelease: true
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
name: release Windows x86
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package-x86:
|
||||||
|
name: build and release windows x86 WPF & Avalonia
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- flavor: wpf
|
||||||
|
project: ./v2rayN/v2rayN/v2rayN.csproj
|
||||||
|
zip_name: v2rayN-windows-86.zip
|
||||||
|
- flavor: desktop
|
||||||
|
project: ./v2rayN/v2rayN.Desktop/v2rayN.Desktop.csproj
|
||||||
|
zip_name: v2rayN-windows-86-desktop.zip
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Purge SDKs
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
echo "Before:"
|
||||||
|
dotnet --list-sdks
|
||||||
|
|
||||||
|
dotnet --list-sdks | % {
|
||||||
|
$_ -match '^\S+\s+\[(.+)\]$' > $null
|
||||||
|
$d = Join-Path $Matches[1] $_.Split()[0]
|
||||||
|
Write-Host "Removing $d"
|
||||||
|
if ($IsWindows) { rm $d -r -fo } else { sudo rm -rf "$d" }
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "After:"
|
||||||
|
dotnet --list-sdks 2>$null; $LASTEXITCODE=0
|
||||||
|
|
||||||
|
- name: Setup .NET 10.0.1xx
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.1xx
|
||||||
|
|
||||||
|
- name: Publish ${{ matrix.flavor }} win-x86
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
dotnet restore "${{ matrix.project }}" -r win-x86
|
||||||
|
|
||||||
|
dotnet publish "${{ matrix.project }}" `
|
||||||
|
-c Release `
|
||||||
|
-r win-x86 `
|
||||||
|
-p:SelfContained=true `
|
||||||
|
-p:EnableWindowsTargeting=true `
|
||||||
|
-o "publish/${{ matrix.flavor }}"
|
||||||
|
|
||||||
|
- name: Publish AmazTool win-x86
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
dotnet publish "./v2rayN/AmazTool/AmazTool.csproj" `
|
||||||
|
-c Release `
|
||||||
|
-r win-x86 `
|
||||||
|
-p:SelfContained=true `
|
||||||
|
-p:PublishTrimmed=true `
|
||||||
|
-p:EnableWindowsTargeting=true `
|
||||||
|
-o "publish/${{ matrix.flavor }}"
|
||||||
|
|
||||||
|
- name: Detect bundled core versions
|
||||||
|
id: detect-core
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path "tmp/probe" | Out-Null
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-windows-64/bin/xray/xray.exe" -OutFile "tmp/probe/xray.exe"
|
||||||
|
$xrayOutput = (& "tmp/probe/xray.exe" version) -join "`n"
|
||||||
|
Write-Host $xrayOutput
|
||||||
|
|
||||||
|
$xrayMatch = [regex]::Match($xrayOutput, 'Xray\s+([0-9.]+)')
|
||||||
|
if ($xrayMatch.Success) {
|
||||||
|
$xrayVer = $xrayMatch.Groups[1].Value
|
||||||
|
} else {
|
||||||
|
throw "Failed to detect Xray version"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-windows-64/bin/sing_box/sing-box.exe" -OutFile "tmp/probe/sing-box.exe"
|
||||||
|
$singOutput = (& "tmp/probe/sing-box.exe" version) -join "`n"
|
||||||
|
Write-Host $singOutput
|
||||||
|
|
||||||
|
$singMatch = [regex]::Match($singOutput, 'sing-box version\s+([0-9.]+)')
|
||||||
|
if ($singMatch.Success) {
|
||||||
|
$singVer = $singMatch.Groups[1].Value
|
||||||
|
} else {
|
||||||
|
throw "Failed to detect sing-box version"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Bundled Xray version: $xrayVer"
|
||||||
|
Write-Host "Bundled sing-box version: $singVer"
|
||||||
|
|
||||||
|
"xray_version=$xrayVer" >> $env:GITHUB_OUTPUT
|
||||||
|
"sing_version=$singVer" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download x86 cores and rule assets
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
Authorization = "Bearer $env:GH_TOKEN"
|
||||||
|
Accept = "application/vnd.github+json"
|
||||||
|
"X-GitHub-Api-Version" = "2022-11-28"
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = "publish/${{ matrix.flavor }}"
|
||||||
|
$bin = Join-Path $root "bin"
|
||||||
|
$xrayDir = Join-Path $bin "xray"
|
||||||
|
$singDir = Join-Path $bin "sing_box"
|
||||||
|
$srssDir = Join-Path $bin "srss"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $xrayDir, $singDir, $srssDir | Out-Null
|
||||||
|
|
||||||
|
$xrayVer = "${{ steps.detect-core.outputs.xray_version }}"
|
||||||
|
$singVer = "${{ steps.detect-core.outputs.sing_version }}"
|
||||||
|
|
||||||
|
$xrayTag = "v$xrayVer"
|
||||||
|
$singTag = "v$singVer"
|
||||||
|
|
||||||
|
$xrayUrl = "https://github.com/autorepobot/Xray/releases/download/$xrayTag/Xray-windows-32.zip"
|
||||||
|
$singUrl = "https://github.com/SagerNet/sing-box/releases/download/$singTag/sing-box-$singVer-windows-386.zip"
|
||||||
|
|
||||||
|
Write-Host "Bundled Xray version: $xrayVer"
|
||||||
|
Write-Host "Bundled sing-box version: $singVer"
|
||||||
|
Write-Host "Xray: $xrayUrl"
|
||||||
|
Write-Host "sing-box: $singUrl"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path "tmp" | Out-Null
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri $xrayUrl -OutFile "tmp/xray.zip"
|
||||||
|
Expand-Archive -Force "tmp/xray.zip" "tmp/xray"
|
||||||
|
|
||||||
|
Get-ChildItem "tmp/xray" -Recurse -Include "*.exe","*.dll" | ForEach-Object {
|
||||||
|
Copy-Item $_.FullName (Join-Path $xrayDir $_.Name) -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri $singUrl -OutFile "tmp/sing-box.zip"
|
||||||
|
Expand-Archive -Force "tmp/sing-box.zip" "tmp/sing-box"
|
||||||
|
|
||||||
|
$singExe = Get-ChildItem "tmp/sing-box" -Recurse -Filter "sing-box.exe" | Select-Object -First 1
|
||||||
|
if (-not $singExe) {
|
||||||
|
throw "sing-box.exe not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item $singExe.FullName "$singDir/sing-box.exe" -Force
|
||||||
|
|
||||||
|
$cronet = Get-ChildItem "tmp/sing-box" -Recurse -Filter "libcronet*.dll" | Select-Object -First 1
|
||||||
|
if ($cronet) {
|
||||||
|
Copy-Item $cronet.FullName "$singDir/libcronet.dll" -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "$bin/geosite.dat"
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "$bin/geoip.dat"
|
||||||
|
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat" -OutFile "$bin/geoip-only-cn-private.dat"
|
||||||
|
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb" -OutFile "$bin/Country.mmdb"
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb" -OutFile "$bin/geoip.metadb"
|
||||||
|
|
||||||
|
$geoipRules = @(
|
||||||
|
"geoip-private.srs",
|
||||||
|
"geoip-cn.srs",
|
||||||
|
"geoip-facebook.srs",
|
||||||
|
"geoip-fastly.srs",
|
||||||
|
"geoip-google.srs",
|
||||||
|
"geoip-netflix.srs",
|
||||||
|
"geoip-telegram.srs",
|
||||||
|
"geoip-twitter.srs"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($f in $geoipRules) {
|
||||||
|
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f" -OutFile "$srssDir/$f"
|
||||||
|
}
|
||||||
|
|
||||||
|
$geositeRules = @(
|
||||||
|
"geosite-cn.srs",
|
||||||
|
"geosite-gfw.srs",
|
||||||
|
"geosite-google.srs",
|
||||||
|
"geosite-greatfire.srs",
|
||||||
|
"geosite-geolocation-cn.srs",
|
||||||
|
"geosite-category-ads-all.srs",
|
||||||
|
"geosite-private.srs"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($f in $geositeRules) {
|
||||||
|
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f" -OutFile "$srssDir/$f"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Package zip
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Compress-Archive `
|
||||||
|
-Path "publish/${{ matrix.flavor }}/*" `
|
||||||
|
-DestinationPath "${{ matrix.zip_name }}" `
|
||||||
|
-Force
|
||||||
|
|
||||||
|
- name: Upload zip artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: release-windows-x86-${{ matrix.flavor }}
|
||||||
|
path: ${{ matrix.zip_name }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release-x86:
|
||||||
|
name: sign and upload windows x86
|
||||||
|
if: inputs.release_tag != ''
|
||||||
|
needs: package-x86
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor: [ wpf, desktop ]
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
artifact_name: release-windows-x86-${{ matrix.flavor }}
|
||||||
|
file_pattern: '*.zip'
|
||||||
|
secrets:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
@@ -10,62 +10,22 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
env:
|
permissions:
|
||||||
OutputArch: "windows-64"
|
contents: write
|
||||||
OutputArchArm: "windows-arm64"
|
actions: read
|
||||||
OutputPath64: "${{ github.workspace }}/v2rayN/Release/windows-64"
|
|
||||||
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/windows-arm64"
|
|
||||||
OutputPath64Sc: "${{ github.workspace }}/v2rayN/Release/windows-64-SelfContained"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
uses: ./.github/workflows/build.yml
|
||||||
matrix:
|
with:
|
||||||
configuration: [Release]
|
target: windows
|
||||||
|
project: ./v2rayN/v2rayN.csproj
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
release-zip:
|
||||||
|
if: inputs.release_tag != ''
|
||||||
steps:
|
needs: build
|
||||||
- name: Checkout
|
uses: ./.github/workflows/package-zip.yml
|
||||||
uses: actions/checkout@v4.2.2
|
with:
|
||||||
|
target: windows
|
||||||
- name: Setup
|
release_tag: ${{ inputs.release_tag }}
|
||||||
uses: actions/setup-dotnet@v4.3.1
|
secrets: inherit
|
||||||
with:
|
|
||||||
dotnet-version: '8.0.x'
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
cd v2rayN
|
|
||||||
dotnet publish ./v2rayN/v2rayN.csproj -c Release -r win-x64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPath64
|
|
||||||
dotnet publish ./v2rayN/v2rayN.csproj -c Release -r win-arm64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPathArm64
|
|
||||||
dotnet publish ./v2rayN/v2rayN.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -o $OutputPath64Sc
|
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-x64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPath64
|
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-arm64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPathArm64
|
|
||||||
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -p:PublishTrimmed=true -o $OutputPath64Sc
|
|
||||||
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v4.6.2
|
|
||||||
with:
|
|
||||||
name: v2rayN-windows
|
|
||||||
path: |
|
|
||||||
${{ github.workspace }}/v2rayN/Release/windows*
|
|
||||||
|
|
||||||
# release zip archive
|
|
||||||
- name: Package release zip archive
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
run: |
|
|
||||||
chmod 755 package-release-zip.sh
|
|
||||||
./package-release-zip.sh $OutputArch $OutputPath64
|
|
||||||
./package-release-zip.sh $OutputArchArm $OutputPathArm64
|
|
||||||
./package-release-zip.sh "windows-64-SelfContained" $OutputPath64Sc
|
|
||||||
|
|
||||||
- name: Upload zip archive to release
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
if: github.event.inputs.release_tag != ''
|
|
||||||
with:
|
|
||||||
file: ${{ github.workspace }}/v2rayN*.zip
|
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
|
||||||
file_glob: true
|
|
||||||
prerelease: true
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
target: # windows linux macos
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
project:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: './v2rayN.Desktop/v2rayN.Desktop.csproj'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: build x64 arm64
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [ x64, arm64 ]
|
||||||
|
runs-on: |-
|
||||||
|
${{
|
||||||
|
case(
|
||||||
|
inputs.target == 'macos', 'macos-latest',
|
||||||
|
inputs.target == 'linux', 'ubuntu-24.04',
|
||||||
|
inputs.target == 'windows', 'windows-latest',
|
||||||
|
'ubuntu-latest'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
env:
|
||||||
|
Output: "${{ github.workspace }}/${{ matrix.arch }}"
|
||||||
|
RID: |-
|
||||||
|
${{
|
||||||
|
case(
|
||||||
|
inputs.target == 'macos', format('osx-{0}', matrix.arch),
|
||||||
|
inputs.target == 'windows', format('win-{0}', matrix.arch),
|
||||||
|
format('{0}-{1}', inputs.target, matrix.arch)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
Project: ${{ inputs.project }}
|
||||||
|
ExtOpt: |-
|
||||||
|
${{
|
||||||
|
case(
|
||||||
|
inputs.target == 'windows', '-p:EnableWindowsTargeting=true',
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
fetch-depth: '0'
|
||||||
|
|
||||||
|
- name: Purge SDKs
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
echo "Before:"
|
||||||
|
dotnet --list-sdks
|
||||||
|
|
||||||
|
dotnet --list-sdks | % {
|
||||||
|
$_ -match '^\S+\s+\[(.+)\]$' > $null
|
||||||
|
$d = Join-Path $Matches[1] $_.Split()[0]
|
||||||
|
Write-Host "Removing $d"
|
||||||
|
if ($IsWindows) { rm $d -r -fo } else { sudo rm -rf "$d" }
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "After:"
|
||||||
|
dotnet --list-sdks 2>$null; $LASTEXITCODE=0
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.1xx
|
||||||
|
|
||||||
|
- name: Build v2rayN
|
||||||
|
shell: bash
|
||||||
|
working-directory: ./v2rayN
|
||||||
|
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt -o $Output
|
||||||
|
|
||||||
|
- name: Build AmazTool
|
||||||
|
shell: bash
|
||||||
|
working-directory: ./v2rayN
|
||||||
|
run: dotnet publish ./AmazTool/AmazTool.csproj -c Release -r $RID -p:SelfContained=true -p:PublishTrimmed=true $ExtOpt -o $Output
|
||||||
|
|
||||||
|
- name: Remove Debug Symbols
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
find ${{ matrix.arch }} -type f -name '*.pdb' -delete
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.arch }}
|
||||||
|
path: ${{ matrix.arch }}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: package and release Zip
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
target: # windows linux macos windows-desktop
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
GPG_PRIVATE_KEY:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
name: package x64 arm64
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [ x64, arm64 ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
Target: |-
|
||||||
|
${{
|
||||||
|
case(
|
||||||
|
inputs.target == 'windows-desktop', 'windows',
|
||||||
|
inputs.target
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
Arch: |-
|
||||||
|
${{
|
||||||
|
case(
|
||||||
|
matrix.arch == 'x64', '64',
|
||||||
|
matrix.arch
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Restore build artifacts
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.arch }}
|
||||||
|
path: v2rayN-${{ env.Target }}-${{ env.Arch }}
|
||||||
|
|
||||||
|
- name: Get v2rayN-core-bin
|
||||||
|
run: wget -nv -O v2rayN-$Target-$Arch.zip "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-$Target-$Arch.zip"
|
||||||
|
|
||||||
|
- name: Package zip archive
|
||||||
|
run: 7z a -tZip v2rayN-$Target-$Arch.zip v2rayN-$Target-$Arch -mx1
|
||||||
|
|
||||||
|
- name: Rename windows-desktop
|
||||||
|
if: inputs.target == 'windows-desktop'
|
||||||
|
run: mv "v2rayN-$Target-$Arch.zip" "v2rayN-$Target-$Arch-desktop.zip"
|
||||||
|
|
||||||
|
- name: Upload zip artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: release-${{ inputs.target }}-${{ matrix.arch }}
|
||||||
|
path: v2rayN*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: sign and upload zip x64 arm64
|
||||||
|
needs: package
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [ x64, arm64 ]
|
||||||
|
uses: ./.github/workflows/upload-sign.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ inputs.release_tag }}
|
||||||
|
artifact_name: release-${{ inputs.target }}-${{ matrix.arch }}
|
||||||
|
file_pattern: '*.zip'
|
||||||
|
secrets:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: publish GPG public key
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
GPG_PRIVATE_KEY:
|
||||||
|
required: true
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
steps:
|
||||||
|
- name: Import GPG private key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
install -m 700 -d ~/.gnupg
|
||||||
|
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
|
||||||
|
|
||||||
|
- name: Export GPG public key
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
fingerprint="$(gpg --batch --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||||
|
[[ -n "$fingerprint" ]] || { echo "No GPG secret key found."; exit 1; }
|
||||||
|
gpg --batch --armor --export "$fingerprint" > v2rayN-public-key.asc
|
||||||
|
|
||||||
|
- name: Upload GPG public key to release
|
||||||
|
uses: svenstaro/upload-release-action@v2
|
||||||
|
with:
|
||||||
|
file: v2rayN-public-key.asc
|
||||||
|
tag: ${{ inputs.release_tag }}
|
||||||
|
prerelease: true
|
||||||
|
overwrite: true
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Code Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'v2rayN/ServiceLib/**'
|
||||||
|
- 'v2rayN/ServiceLib.UdpTest/**'
|
||||||
|
- 'v2rayN/ServiceLib.Tests/**'
|
||||||
|
- 'v2rayN/Directory.Build.*'
|
||||||
|
- 'v2rayN/Directory.Packages.props'
|
||||||
|
- 'global.json'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'v2rayN/ServiceLib/**'
|
||||||
|
- 'v2rayN/ServiceLib.UdpTest/**'
|
||||||
|
- 'v2rayN/ServiceLib.Tests/**'
|
||||||
|
- 'v2rayN/Directory.Build.*'
|
||||||
|
- 'v2rayN/Directory.Packages.props'
|
||||||
|
- 'global.json'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
checks: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v6
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.x'
|
||||||
|
dotnet-quality: 'ga'
|
||||||
|
|
||||||
|
- name: Test Code
|
||||||
|
run: dotnet test --project ./v2rayN/ServiceLib.Tests -c Release --results-directory ./TestResults -- --report-trx
|
||||||
|
|
||||||
|
- name: Comment PR with results
|
||||||
|
if: always()
|
||||||
|
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||||
|
with:
|
||||||
|
files: ./TestResults/*.trx
|
||||||
|
comment_mode: failures
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: sign and upload release files
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
artifact_name:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
file_pattern:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: '**/*'
|
||||||
|
content_type:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: application/octet-stream
|
||||||
|
secrets:
|
||||||
|
GPG_PRIVATE_KEY:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload:
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
steps:
|
||||||
|
- name: Download release artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.artifact_name }}
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Import GPG private key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
install -m 700 -d ~/.gnupg
|
||||||
|
printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import
|
||||||
|
|
||||||
|
- name: Sign release files
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd dist
|
||||||
|
shopt -s globstar nullglob
|
||||||
|
files=( ${{ inputs.file_pattern }} )
|
||||||
|
(( ${#files[@]} )) || { echo "No files matched: ${{ inputs.file_pattern }}"; exit 1; }
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
gpg --batch --yes --armor --detach-sign --output "$f.sig" "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload release files
|
||||||
|
uses: svenstaro/upload-release-action@v2
|
||||||
|
with:
|
||||||
|
file: dist/${{ inputs.file_pattern }}
|
||||||
|
tag: ${{ inputs.release_tag }}
|
||||||
|
file_glob: true
|
||||||
|
prerelease: true
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
- name: Upload release signatures
|
||||||
|
uses: svenstaro/upload-release-action@v2
|
||||||
|
with:
|
||||||
|
file: dist/${{ inputs.file_pattern }}.sig
|
||||||
|
tag: ${{ inputs.release_tag }}
|
||||||
|
file_glob: true
|
||||||
|
prerelease: true
|
||||||
|
overwrite: true
|
||||||
@@ -22,10 +22,23 @@ jobs:
|
|||||||
$github = Invoke-RestMethod -uri "https://api.github.com/repos/2dust/v2rayN/releases"
|
$github = Invoke-RestMethod -uri "https://api.github.com/repos/2dust/v2rayN/releases"
|
||||||
|
|
||||||
$targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
|
$targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
|
||||||
$installerUrl = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'v2rayN-windows-64\.zip*' | Select -ExpandProperty browser_download_url
|
|
||||||
|
$assets = $targetRelease | Select -ExpandProperty assets -First 1
|
||||||
|
$x64InstallerUrl = $assets | Where-Object { $_.name -eq 'v2rayN-windows-64.zip' } | Select -ExpandProperty browser_download_url
|
||||||
|
$arm64InstallerUrl = $assets | Where-Object { $_.name -eq 'v2rayN-windows-arm64.zip' } | Select -ExpandProperty browser_download_url
|
||||||
|
|
||||||
|
if (-not $x64InstallerUrl -or -not $arm64InstallerUrl) {
|
||||||
|
throw "Could not find required installers in release assets."
|
||||||
|
}
|
||||||
|
|
||||||
$ver = $targetRelease.tag_name
|
$ver = $targetRelease.tag_name
|
||||||
|
|
||||||
# getting latest wingetcreate file
|
# getting latest wingetcreate file
|
||||||
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||||
.\wingetcreate.exe update $wingetPackage -s -v $ver -u "$installerUrl|x64" -t $gitToken
|
|
||||||
|
Write-Host "Updating with both x64 and arm64 installers"
|
||||||
|
Write-Host "Version: $ver"
|
||||||
|
Write-Host "x64 URL: $x64InstallerUrl"
|
||||||
|
Write-Host "arm64 URL: $arm64InstallerUrl"
|
||||||
|
|
||||||
|
.\wingetcreate.exe update $wingetPackage -s -v $ver -u "$x64InstallerUrl|x64" "$arm64InstallerUrl|arm64" -t $gitToken
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ StyleCopReport.xml
|
|||||||
*.rsp
|
*.rsp
|
||||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
!Directory.Build.rsp
|
!Directory.Build.rsp
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
build/
|
||||||
*.sbr
|
*.sbr
|
||||||
*.tlb
|
*.tlb
|
||||||
*.tli
|
*.tli
|
||||||
@@ -397,4 +400,5 @@ FodyWeavers.xsd
|
|||||||
*.msp
|
*.msp
|
||||||
|
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
|
.idea/
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
@@ -632,7 +632,7 @@ state the exclusion of warranty; and each file should have at least
|
|||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) 2019-Present 2dust
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
|
|||||||
If the program does terminal interaction, make it output a short
|
If the program does terminal interaction, make it output a short
|
||||||
notice like this when it starts in an interactive mode:
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
v2rayN Copyright (C) 2019-Present 2dust
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
under certain conditions; type `show c' for details.
|
||||||
|
|||||||
@@ -1,16 +1,80 @@
|
|||||||
# v2rayN
|
# v2rayN
|
||||||
A GUI client for Windows, Linux and macOS, support [Xray](https://github.com/XTLS/Xray-core) and [sing-box](https://github.com/SagerNet/sing-box/releases) and [others](https://github.com/2dust/v2rayN/wiki/List-of-supported-cores)
|
|
||||||
|
|
||||||
|
### A GUI client for Windows, Linux and macOS. Support [Xray](https://github.com/XTLS/Xray-core) and [sing-box](https://github.com/SagerNet/sing-box) and [others](https://github.com/2dust/v2rayN/wiki/List-of-supported-cores)
|
||||||
|
|
||||||
[](https://github.com/2dust/v2rayN/commits/master)
|
|
||||||
[](https://www.codefactor.io/repository/github/2dust/v2rayn)
|
[](https://www.codefactor.io/repository/github/2dust/v2rayn)
|
||||||
[](https://github.com/2dust/v2rayN/releases)
|
[](https://github.com/2dust/v2rayN/releases)
|
||||||
[](https://t.me/v2rayn)
|
[](https://github.com/2dust/v2rayN/releases)
|
||||||
|
[](https://t.me/v2rayn)
|
||||||
|
|
||||||
|
[](https://github.com/2dust/v2rayN)
|
||||||
|
[](https://github.com/2dust/v2rayN)
|
||||||
|
[](https://github.com/2dust/v2rayN)
|
||||||
|
[](https://github.com/2dust/v2rayN)
|
||||||
|
|
||||||
|
|
||||||
## How to use
|
---
|
||||||
|
|
||||||
Read the [Wiki](https://github.com/2dust/v2rayN/wiki) for details.
|
## Download / 下载
|
||||||
|
|
||||||
## Telegram Channel
|
Download the latest release here:
|
||||||
[github_2dust](https://t.me/github_2dust)
|
|
||||||
|
在这里下载最新版本:
|
||||||
|
|
||||||
|
[https://github.com/2dust/v2rayN/releases](https://github.com/2dust/v2rayN/releases)
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> v2rayN is the desktop version. For the mobile version, please visit the v2rayNG \
|
||||||
|
> v2rayN 是电脑版,手机版请访问 v2rayNG
|
||||||
|
>
|
||||||
|
> https://github.com/2dust/v2rayNG
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation / 使用文档
|
||||||
|
|
||||||
|
Read the Wiki for usage guides and configuration details.
|
||||||
|
|
||||||
|
请阅读 Wiki 获取使用说明和配置教程。
|
||||||
|
|
||||||
|
[https://github.com/2dust/v2rayN/wiki](https://github.com/2dust/v2rayN/wiki)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Platforms / 支持平台
|
||||||
|
|
||||||
|
| Platform / 平台 | x64 | x86 | arm64 | riscv64 | loong64 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Windows | ✅ | ✅ | ✅ | - | - |
|
||||||
|
| Linux | ✅ | - | ✅ | ✅ | ✅ |
|
||||||
|
| macOS | ✅ | - | ✅ | - | - |
|
||||||
|
|
||||||
|
Minimum OS requirements: [Release files introduction](https://github.com/2dust/v2rayN/wiki/Release-files-introduction) / 最低系统要求:[发布文件介绍](https://github.com/2dust/v2rayN/wiki/Release-files-introduction)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPG Verification / GPG 签名校验
|
||||||
|
|
||||||
|
Release files are signed with GPG to verify authenticity and integrity, helping prevent mirror, ISP, or CDN hijacking.
|
||||||
|
|
||||||
|
发布文件已使用 GPG 签名,可用于校验文件真实性与完整性,预防镜像站、运营商或 CDN 劫持。
|
||||||
|
|
||||||
|
### Fingerprint / 公钥指纹
|
||||||
|
|
||||||
|
```text
|
||||||
|
7694 5E9F 3E9A 168F 8070 F195 805D 661C
|
||||||
|
134D FAF6 8903 C199 463C 31E5 AE90 3AE0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community / 社区
|
||||||
|
|
||||||
|
Telegram Group / Telegram 群组:
|
||||||
|
|
||||||
|
[https://t.me/v2rayN](https://t.me/v2rayN)
|
||||||
|
|
||||||
|
Telegram Channel / Telegram 频道:
|
||||||
|
|
||||||
|
[https://t.me/github_2dust](https://t.me/github_2dust)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
sudo apt update -y
|
|
||||||
sudo apt install -y libfuse2
|
|
||||||
wget -O pkg2appimage https://github.com/AppImageCommunity/pkg2appimage/releases/download/continuous/pkg2appimage-1eceb30-x86_64.AppImage
|
|
||||||
chmod a+x pkg2appimage
|
|
||||||
export AppImageOutputArch=$OutputArch
|
|
||||||
export OutputPath=$OutputPath64
|
|
||||||
./pkg2appimage ./pkg2appimage.yml
|
|
||||||
mv out/*.AppImage v2rayN-${AppImageOutputArch}.AppImage
|
|
||||||
export AppImageOutputArch=$OutputArchArm
|
|
||||||
export OutputPath=$OutputPathArm64
|
|
||||||
./pkg2appimage ./pkg2appimage.yml
|
|
||||||
mv out/*.AppImage v2rayN-${AppImageOutputArch}.AppImage
|
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_ARG=""
|
||||||
|
WITH_CORE="both"
|
||||||
|
FORCE_NETCORE=0
|
||||||
|
BUILD_FROM=""
|
||||||
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
|
MIN_KERNEL="6.12"
|
||||||
|
PKGROOT="v2rayN-publish"
|
||||||
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
|
OUTPUT_DIR="${HOME}/debbuild"
|
||||||
|
DOTNET_TFM="net10.0"
|
||||||
|
DOTNET_LOONGARCH_VERSION="10.0.111"
|
||||||
|
DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
|
||||||
|
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
||||||
|
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
||||||
|
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
||||||
|
|
||||||
|
OS_ID=""
|
||||||
|
OS_NAME=""
|
||||||
|
OS_VERSION_ID=""
|
||||||
|
HOST_ARCH=""
|
||||||
|
SCRIPT_DIR=""
|
||||||
|
PROJECT=""
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
|
declare -a BUILT_DEBS=()
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
||||||
|
VERSION_ARG="$first_arg"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
||||||
|
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
||||||
|
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
||||||
|
--netcore) FORCE_NETCORE=1; shift ;;
|
||||||
|
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
||||||
|
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_environment() {
|
||||||
|
local current_kernel=""
|
||||||
|
local lowest=""
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
OS_ID="${ID:-}"
|
||||||
|
OS_NAME="${NAME:-$OS_ID}"
|
||||||
|
OS_VERSION_ID="${VERSION_ID:-}"
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS_ID" in
|
||||||
|
debian)
|
||||||
|
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
||||||
|
This script only supports: Debian."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
loongarch64) ;;
|
||||||
|
*) die "Only supports loongarch64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
current_kernel="$(uname -r)"
|
||||||
|
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
||||||
|
|
||||||
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local install_ok=0
|
||||||
|
local tmp_dotnet=""
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get -y install \
|
||||||
|
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
|
||||||
|
desktop-file-utils xdg-utils wget gcc make pkg-config \
|
||||||
|
libicu-dev libssl-dev libfontconfig1 libfreetype6 zlib1g
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.dotnet"
|
||||||
|
tmp_dotnet="$(mktemp -d)"
|
||||||
|
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
||||||
|
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
||||||
|
rm -rf "$tmp_dotnet"
|
||||||
|
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
|
dotnet --info >/dev/null 2>&1 && install_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
|
echo "dotnet-loongarch SDK, curl, unzip, tar, rsync, git, gcc, make, dpkg-deb, fakeroot, libicu-dev, libssl-dev"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workspace() {
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$PROJECT_HINT"
|
||||||
|
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
||||||
|
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_channel() {
|
||||||
|
local ch="latest"
|
||||||
|
local sel=""
|
||||||
|
|
||||||
|
if [[ -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
case "$BUILD_FROM" in
|
||||||
|
1) echo "latest"; return 0 ;;
|
||||||
|
2) echo "prerelease"; return 0 ;;
|
||||||
|
3) echo "keep"; return 0 ;;
|
||||||
|
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
echo "[?] Choose v2rayN release channel:" >&2
|
||||||
|
echo " 1) Latest (stable) [default]" >&2
|
||||||
|
echo " 2) Pre-release (preview)" >&2
|
||||||
|
echo " 3) Keep current (do nothing)" >&2
|
||||||
|
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
||||||
|
|
||||||
|
if read -r sel </dev/tty; then
|
||||||
|
case "${sel:-}" in
|
||||||
|
2) ch="prerelease" ;;
|
||||||
|
3) ch="keep" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ch"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||||
|
| jq -re '.tag_name' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_prerelease() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
||||||
|
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_submodules() {
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git_try_checkout() {
|
||||||
|
local want="$1"
|
||||||
|
local ref=""
|
||||||
|
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
git fetch --tags --force --prune --depth=1 || true
|
||||||
|
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
||||||
|
|
||||||
|
if [[ -n "$ref" ]]; then
|
||||||
|
echo "[OK] Found ref '${ref}', checking out..."
|
||||||
|
git checkout -f "$ref"
|
||||||
|
sync_submodules
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_channel_or_keep() {
|
||||||
|
local ch="$1"
|
||||||
|
local tag=""
|
||||||
|
|
||||||
|
if [[ "$ch" == "keep" ]]; then
|
||||||
|
echo "[*] Keep current repository state (no checkout)."
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
||||||
|
|
||||||
|
case "$ch" in
|
||||||
|
latest) tag="$(get_latest_tag_latest || true)" ;;
|
||||||
|
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
||||||
|
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
||||||
|
|
||||||
|
echo "[*] Latest tag for '${ch}': ${tag}"
|
||||||
|
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
||||||
|
VERSION="${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
if [[ -n "${VERSION_ARG:-}" ]]; then
|
||||||
|
local clean_ver="${VERSION_ARG#v}"
|
||||||
|
|
||||||
|
if git_try_checkout "$clean_ver"; then
|
||||||
|
VERSION="$clean_ver"
|
||||||
|
else
|
||||||
|
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current directory is not a git repo; proceeding on current tree."
|
||||||
|
VERSION="${VERSION_ARG:-0.0.0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
xray_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-loongarch64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-loong64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
singbox_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-loongarch64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-loong64.tar.gz" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-loongarch64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-loong64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
download_xray() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${XRAY_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_singbox() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${SING_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local bin=""
|
||||||
|
local cronet=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
|
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
unify_geo_layout() {
|
||||||
|
local outroot="$1"
|
||||||
|
local n
|
||||||
|
local names=(
|
||||||
|
geosite.dat
|
||||||
|
geoip.dat
|
||||||
|
geoip-only-cn-private.dat
|
||||||
|
Country.mmdb
|
||||||
|
geoip.metadb
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
||||||
|
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
download_geo_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local bin_dir="$outroot/bin"
|
||||||
|
local srss_dir="$bin_dir/srss"
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
|
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
|
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_zip_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local nested_dir=""
|
||||||
|
|
||||||
|
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$tmp/bin/" "$outroot/bin/"
|
||||||
|
else
|
||||||
|
rsync -a "$tmp/" "$outroot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
||||||
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
|
rm -rf "$nested_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "[+] Bundle extracted to $outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_netcore_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_runtime_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
|
echo "[*] Using v2rayN bundle archive."
|
||||||
|
else
|
||||||
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[*] --netcore specified: use separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_target() {
|
||||||
|
local short="$1"
|
||||||
|
|
||||||
|
case "$short" in
|
||||||
|
loongarch64) printf '%s\n%s\n' "linux-loongarch64" "loong64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use loongarch64)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_binary() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
dotnet clean "$PROJECT" -c Release
|
||||||
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
|
dotnet restore "$PROJECT"
|
||||||
|
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_launcher_file() {
|
||||||
|
local stage="$1"
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$stage/usr/bin/v2rayn" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="/opt/v2rayN"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
if [[ -x "$DIR/v2rayN" ]]; then
|
||||||
|
exec "$DIR/v2rayN" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
||||||
|
if [[ -f "$DIR/$dll" ]]; then
|
||||||
|
exec /usr/bin/dotnet "$DIR/$dll" "$@"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
|
ls -l "$DIR" >&2 || true
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_desktop_file() {
|
||||||
|
local stage="$1"
|
||||||
|
|
||||||
|
install -m 644 /dev/stdin "$stage/usr/share/applications/v2rayn.desktop" <<'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=v2rayN
|
||||||
|
Comment=v2rayN for Debian GNU Linux
|
||||||
|
Exec=v2rayn
|
||||||
|
Icon=v2rayn
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_maintainer_scripts() {
|
||||||
|
local debian_dir="$1"
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$debian_dir/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$debian_dir/postrm" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
package_binary() {
|
||||||
|
local short="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local deb_arch="$3"
|
||||||
|
local pubdir=""
|
||||||
|
local workdir=""
|
||||||
|
local stage=""
|
||||||
|
local debian_dir=""
|
||||||
|
local project_dir=""
|
||||||
|
local icon_candidate=""
|
||||||
|
local shlibs_depends=""
|
||||||
|
local extra_depends=""
|
||||||
|
local final_depends=""
|
||||||
|
local multiarch=""
|
||||||
|
local sys_libdir=""
|
||||||
|
local sys_usrlibdir=""
|
||||||
|
local deb_out=""
|
||||||
|
|
||||||
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
|
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
|
|
||||||
|
stage="$workdir/${PKGROOT}_${VERSION}_${deb_arch}"
|
||||||
|
debian_dir="$stage/DEBIAN"
|
||||||
|
|
||||||
|
mkdir -p "$stage/opt/v2rayN" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/256x256/apps" "$debian_dir"
|
||||||
|
cp -a "$pubdir/." "$stage/opt/v2rayN/"
|
||||||
|
|
||||||
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
|
[[ -f "$icon_candidate" ]] && cp "$icon_candidate" "$stage/usr/share/icons/hicolor/256x256/apps/v2rayn.png" || true
|
||||||
|
|
||||||
|
stage_runtime_assets "$stage/opt/v2rayN" "$rid"
|
||||||
|
write_launcher_file "$stage"
|
||||||
|
write_desktop_file "$stage"
|
||||||
|
write_maintainer_scripts "$debian_dir"
|
||||||
|
|
||||||
|
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
||||||
|
|
||||||
|
mkdir -p "$workdir/debian"
|
||||||
|
cat > "$workdir/debian/control" <<EOF
|
||||||
|
Source: v2rayn
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: 2dust <noreply@github.com>
|
||||||
|
Standards-Version: 4.7.0
|
||||||
|
|
||||||
|
Package: v2rayn
|
||||||
|
Architecture: ${deb_arch}
|
||||||
|
Description: v2rayN
|
||||||
|
EOF
|
||||||
|
|
||||||
|
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
|
||||||
|
sys_libdir="/lib/$multiarch"
|
||||||
|
sys_usrlibdir="/usr/lib/$multiarch"
|
||||||
|
|
||||||
|
: > "$debian_dir/substvars"
|
||||||
|
|
||||||
|
mapfile -t ELF_FILES < <(
|
||||||
|
find "$stage/opt/v2rayN" -type f \( -name "*.so*" -o -perm -111 \) ! -name 'libcoreclrtraceptprovider.so'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${#ELF_FILES[@]}" -gt 0 ]]; then
|
||||||
|
(
|
||||||
|
cd "$workdir"
|
||||||
|
dpkg-shlibdeps \
|
||||||
|
-l"$stage/opt/v2rayN" \
|
||||||
|
-l"$sys_libdir" \
|
||||||
|
-l"$sys_usrlibdir" \
|
||||||
|
-T"$debian_dir/substvars" \
|
||||||
|
"${ELF_FILES[@]}"
|
||||||
|
) >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
||||||
|
|
||||||
|
if [[ -n "$shlibs_depends" ]]; then
|
||||||
|
shlibs_depends="$(echo "$shlibs_depends" \
|
||||||
|
| sed -E 's/ *\([^)]*\)//g' \
|
||||||
|
| sed -E 's/ *, */, /g' \
|
||||||
|
| sed -E 's/^, *//; s/, *$//')"
|
||||||
|
final_depends="${shlibs_depends}, ${extra_depends}"
|
||||||
|
else
|
||||||
|
final_depends="${extra_depends}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$debian_dir/control" <<EOF
|
||||||
|
Package: v2rayn
|
||||||
|
Version: ${VERSION}
|
||||||
|
Architecture: ${deb_arch}
|
||||||
|
Maintainer: 2dust <noreply@github.com>
|
||||||
|
Homepage: https://github.com/2dust/v2rayN
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Depends: ${final_depends}
|
||||||
|
Description: v2rayN (Avalonia) GUI client for Linux
|
||||||
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
|
||||||
|
Shadowsocks / tuic / WireGuard.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
find "$stage/opt/v2rayN" -type d -exec chmod 0755 {} +
|
||||||
|
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
||||||
|
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
|
||||||
|
|
||||||
|
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
|
||||||
|
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
||||||
|
|
||||||
|
echo "Build done for $short. DEB at:"
|
||||||
|
echo " $deb_out"
|
||||||
|
BUILT_DEBS+=("$deb_out")
|
||||||
|
}
|
||||||
|
|
||||||
|
select_targets() {
|
||||||
|
printf '%s\n' loongarch64
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one_target() {
|
||||||
|
local short="$1"
|
||||||
|
local meta=()
|
||||||
|
local rid=""
|
||||||
|
local deb_arch=""
|
||||||
|
|
||||||
|
mapfile -t meta < <(describe_target "$short") || return 1
|
||||||
|
rid="${meta[0]}"
|
||||||
|
deb_arch="${meta[1]}"
|
||||||
|
|
||||||
|
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
||||||
|
publish_binary "$rid"
|
||||||
|
package_binary "$short" "$rid" "$deb_arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
local pkg=""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Build Summary ================="
|
||||||
|
if [[ "${#BUILT_DEBS[@]}" -gt 0 ]]; then
|
||||||
|
echo "Output directory: $OUTPUT_DIR"
|
||||||
|
for pkg in "${BUILT_DEBS[@]}"; do
|
||||||
|
echo "$pkg"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No DEBs detected in summary (check build logs above)."
|
||||||
|
fi
|
||||||
|
echo "==============================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=()
|
||||||
|
local arch=""
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
detect_environment
|
||||||
|
install_dependencies
|
||||||
|
prepare_workspace
|
||||||
|
resolve_version
|
||||||
|
|
||||||
|
mapfile -t targets < <(select_targets)
|
||||||
|
|
||||||
|
for arch in "${targets[@]}"; do
|
||||||
|
build_one_target "$arch"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_ARG=""
|
||||||
|
WITH_CORE="both"
|
||||||
|
FORCE_NETCORE=0
|
||||||
|
BUILD_FROM=""
|
||||||
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
|
MIN_KERNEL="5.10"
|
||||||
|
PKGROOT="v2rayN-publish"
|
||||||
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
|
OUTPUT_DIR="${HOME}/debbuild"
|
||||||
|
DOTNET_RISCV_VERSION="10.0.111"
|
||||||
|
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
||||||
|
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
||||||
|
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
||||||
|
|
||||||
|
OS_ID=""
|
||||||
|
OS_NAME=""
|
||||||
|
OS_VERSION_ID=""
|
||||||
|
HOST_ARCH=""
|
||||||
|
SCRIPT_DIR=""
|
||||||
|
PROJECT=""
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
|
declare -a BUILT_DEBS=()
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
||||||
|
VERSION_ARG="$first_arg"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
||||||
|
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
||||||
|
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
||||||
|
--netcore) FORCE_NETCORE=1; shift ;;
|
||||||
|
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
||||||
|
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_environment() {
|
||||||
|
local current_kernel=""
|
||||||
|
local lowest=""
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
OS_ID="${ID:-}"
|
||||||
|
OS_NAME="${NAME:-$OS_ID}"
|
||||||
|
OS_VERSION_ID="${VERSION_ID:-}"
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS_ID" in
|
||||||
|
debian)
|
||||||
|
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
||||||
|
This script only supports: Debian."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
riscv64) ;;
|
||||||
|
*) die "Only supports riscv64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
current_kernel="$(uname -r)"
|
||||||
|
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
||||||
|
|
||||||
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local install_ok=0
|
||||||
|
local tmp_dotnet=""
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get -y install \
|
||||||
|
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
|
||||||
|
desktop-file-utils xdg-utils wget gcc make pkg-config \
|
||||||
|
libicu-dev libssl-dev libfontconfig1 libfreetype6 zlib1g
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.dotnet"
|
||||||
|
tmp_dotnet="$(mktemp -d)"
|
||||||
|
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
||||||
|
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
||||||
|
rm -rf "$tmp_dotnet"
|
||||||
|
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
|
dotnet --info >/dev/null 2>&1 && install_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
|
echo "dotnet-riscv SDK, curl, unzip, tar, rsync, git, gcc, make, dpkg-deb, fakeroot, libicu-dev, libssl-dev"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workspace() {
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$PROJECT_HINT"
|
||||||
|
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
||||||
|
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_channel() {
|
||||||
|
local ch="latest"
|
||||||
|
local sel=""
|
||||||
|
|
||||||
|
if [[ -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
case "$BUILD_FROM" in
|
||||||
|
1) echo "latest"; return 0 ;;
|
||||||
|
2) echo "prerelease"; return 0 ;;
|
||||||
|
3) echo "keep"; return 0 ;;
|
||||||
|
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
echo "[?] Choose v2rayN release channel:" >&2
|
||||||
|
echo " 1) Latest (stable) [default]" >&2
|
||||||
|
echo " 2) Pre-release (preview)" >&2
|
||||||
|
echo " 3) Keep current (do nothing)" >&2
|
||||||
|
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
||||||
|
|
||||||
|
if read -r sel </dev/tty; then
|
||||||
|
case "${sel:-}" in
|
||||||
|
2) ch="prerelease" ;;
|
||||||
|
3) ch="keep" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ch"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||||
|
| jq -re '.tag_name' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_prerelease() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
||||||
|
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_submodules() {
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git_try_checkout() {
|
||||||
|
local want="$1"
|
||||||
|
local ref=""
|
||||||
|
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
git fetch --tags --force --prune --depth=1 || true
|
||||||
|
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
||||||
|
|
||||||
|
if [[ -n "$ref" ]]; then
|
||||||
|
echo "[OK] Found ref '${ref}', checking out..."
|
||||||
|
git checkout -f "$ref"
|
||||||
|
sync_submodules
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_channel_or_keep() {
|
||||||
|
local ch="$1"
|
||||||
|
local tag=""
|
||||||
|
|
||||||
|
if [[ "$ch" == "keep" ]]; then
|
||||||
|
echo "[*] Keep current repository state (no checkout)."
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
||||||
|
|
||||||
|
case "$ch" in
|
||||||
|
latest) tag="$(get_latest_tag_latest || true)" ;;
|
||||||
|
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
||||||
|
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
||||||
|
|
||||||
|
echo "[*] Latest tag for '${ch}': ${tag}"
|
||||||
|
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
||||||
|
VERSION="${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
if [[ -n "${VERSION_ARG:-}" ]]; then
|
||||||
|
local clean_ver="${VERSION_ARG#v}"
|
||||||
|
|
||||||
|
if git_try_checkout "$clean_ver"; then
|
||||||
|
VERSION="$clean_ver"
|
||||||
|
else
|
||||||
|
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current directory is not a git repo; proceeding on current tree."
|
||||||
|
VERSION="${VERSION_ARG:-0.0.0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
xray_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-riscv64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
singbox_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-riscv64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-riscv64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
download_xray() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${XRAY_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_singbox() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${SING_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local bin=""
|
||||||
|
local cronet=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
|
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
unify_geo_layout() {
|
||||||
|
local outroot="$1"
|
||||||
|
local n
|
||||||
|
local names=(
|
||||||
|
geosite.dat
|
||||||
|
geoip.dat
|
||||||
|
geoip-only-cn-private.dat
|
||||||
|
Country.mmdb
|
||||||
|
geoip.metadb
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
||||||
|
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
download_geo_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local bin_dir="$outroot/bin"
|
||||||
|
local srss_dir="$bin_dir/srss"
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
|
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
|
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_zip_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local nested_dir=""
|
||||||
|
|
||||||
|
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$tmp/bin/" "$outroot/bin/"
|
||||||
|
else
|
||||||
|
rsync -a "$tmp/" "$outroot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
||||||
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
|
rm -rf "$nested_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "[+] Bundle extracted to $outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_netcore_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_runtime_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
|
echo "[*] Using v2rayN bundle archive."
|
||||||
|
else
|
||||||
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[*] --netcore specified: use separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_target() {
|
||||||
|
local short="$1"
|
||||||
|
|
||||||
|
case "$short" in
|
||||||
|
riscv64) printf '%s\n%s\n' "linux-riscv64" "riscv64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use riscv64)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_binary() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
dotnet clean "$PROJECT" -c Release
|
||||||
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
|
dotnet restore "$PROJECT"
|
||||||
|
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_launcher_file() {
|
||||||
|
local stage="$1"
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$stage/usr/bin/v2rayn" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="/opt/v2rayN"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
if [[ -x "$DIR/v2rayN" ]]; then
|
||||||
|
exec "$DIR/v2rayN" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
||||||
|
if [[ -f "$DIR/$dll" ]]; then
|
||||||
|
exec /usr/bin/dotnet "$DIR/$dll" "$@"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
|
ls -l "$DIR" >&2 || true
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_desktop_file() {
|
||||||
|
local stage="$1"
|
||||||
|
|
||||||
|
install -m 644 /dev/stdin "$stage/usr/share/applications/v2rayn.desktop" <<'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=v2rayN
|
||||||
|
Comment=v2rayN for Debian GNU Linux
|
||||||
|
Exec=v2rayn
|
||||||
|
Icon=v2rayn
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_maintainer_scripts() {
|
||||||
|
local debian_dir="$1"
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$debian_dir/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$debian_dir/postrm" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
package_binary() {
|
||||||
|
local short="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local deb_arch="$3"
|
||||||
|
local pubdir=""
|
||||||
|
local workdir=""
|
||||||
|
local stage=""
|
||||||
|
local debian_dir=""
|
||||||
|
local project_dir=""
|
||||||
|
local icon_candidate=""
|
||||||
|
local shlibs_depends=""
|
||||||
|
local extra_depends=""
|
||||||
|
local final_depends=""
|
||||||
|
local multiarch=""
|
||||||
|
local sys_libdir=""
|
||||||
|
local sys_usrlibdir=""
|
||||||
|
local deb_out=""
|
||||||
|
|
||||||
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
|
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
|
|
||||||
|
stage="$workdir/${PKGROOT}_${VERSION}_${deb_arch}"
|
||||||
|
debian_dir="$stage/DEBIAN"
|
||||||
|
|
||||||
|
mkdir -p "$stage/opt/v2rayN" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/256x256/apps" "$debian_dir"
|
||||||
|
cp -a "$pubdir/." "$stage/opt/v2rayN/"
|
||||||
|
|
||||||
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
|
[[ -f "$icon_candidate" ]] && cp "$icon_candidate" "$stage/usr/share/icons/hicolor/256x256/apps/v2rayn.png" || true
|
||||||
|
|
||||||
|
stage_runtime_assets "$stage/opt/v2rayN" "$rid"
|
||||||
|
write_launcher_file "$stage"
|
||||||
|
write_desktop_file "$stage"
|
||||||
|
write_maintainer_scripts "$debian_dir"
|
||||||
|
|
||||||
|
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
||||||
|
|
||||||
|
mkdir -p "$workdir/debian"
|
||||||
|
cat > "$workdir/debian/control" <<EOF
|
||||||
|
Source: v2rayn
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: 2dust <noreply@github.com>
|
||||||
|
Standards-Version: 4.7.0
|
||||||
|
|
||||||
|
Package: v2rayn
|
||||||
|
Architecture: ${deb_arch}
|
||||||
|
Description: v2rayN
|
||||||
|
EOF
|
||||||
|
|
||||||
|
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
|
||||||
|
sys_libdir="/lib/$multiarch"
|
||||||
|
sys_usrlibdir="/usr/lib/$multiarch"
|
||||||
|
|
||||||
|
: > "$debian_dir/substvars"
|
||||||
|
|
||||||
|
mapfile -t ELF_FILES < <(
|
||||||
|
find "$stage/opt/v2rayN" -type f \( -name "*.so*" -o -perm -111 \) ! -name 'libcoreclrtraceptprovider.so'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${#ELF_FILES[@]}" -gt 0 ]]; then
|
||||||
|
(
|
||||||
|
cd "$workdir"
|
||||||
|
dpkg-shlibdeps \
|
||||||
|
-l"$stage/opt/v2rayN" \
|
||||||
|
-l"$sys_libdir" \
|
||||||
|
-l"$sys_usrlibdir" \
|
||||||
|
-T"$debian_dir/substvars" \
|
||||||
|
"${ELF_FILES[@]}"
|
||||||
|
) >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
||||||
|
|
||||||
|
if [[ -n "$shlibs_depends" ]]; then
|
||||||
|
shlibs_depends="$(echo "$shlibs_depends" \
|
||||||
|
| sed -E 's/ *\([^)]*\)//g' \
|
||||||
|
| sed -E 's/ *, */, /g' \
|
||||||
|
| sed -E 's/^, *//; s/, *$//')"
|
||||||
|
final_depends="${shlibs_depends}, ${extra_depends}"
|
||||||
|
else
|
||||||
|
final_depends="${extra_depends}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$debian_dir/control" <<EOF
|
||||||
|
Package: v2rayn
|
||||||
|
Version: ${VERSION}
|
||||||
|
Architecture: ${deb_arch}
|
||||||
|
Maintainer: 2dust <noreply@github.com>
|
||||||
|
Homepage: https://github.com/2dust/v2rayN
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Depends: ${final_depends}
|
||||||
|
Description: v2rayN (Avalonia) GUI client for Linux
|
||||||
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
|
||||||
|
Shadowsocks / tuic / WireGuard.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
find "$stage/opt/v2rayN" -type d -exec chmod 0755 {} +
|
||||||
|
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
||||||
|
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
|
||||||
|
|
||||||
|
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
|
||||||
|
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
||||||
|
|
||||||
|
echo "Build done for $short. DEB at:"
|
||||||
|
echo " $deb_out"
|
||||||
|
BUILT_DEBS+=("$deb_out")
|
||||||
|
}
|
||||||
|
|
||||||
|
select_targets() {
|
||||||
|
printf '%s\n' riscv64
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one_target() {
|
||||||
|
local short="$1"
|
||||||
|
local meta=()
|
||||||
|
local rid=""
|
||||||
|
local deb_arch=""
|
||||||
|
|
||||||
|
mapfile -t meta < <(describe_target "$short") || return 1
|
||||||
|
rid="${meta[0]}"
|
||||||
|
deb_arch="${meta[1]}"
|
||||||
|
|
||||||
|
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
||||||
|
publish_binary "$rid"
|
||||||
|
package_binary "$short" "$rid" "$deb_arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
local pkg=""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Build Summary ================="
|
||||||
|
if [[ "${#BUILT_DEBS[@]}" -gt 0 ]]; then
|
||||||
|
echo "Output directory: $OUTPUT_DIR"
|
||||||
|
for pkg in "${BUILT_DEBS[@]}"; do
|
||||||
|
echo "$pkg"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No DEBs detected in summary (check build logs above)."
|
||||||
|
fi
|
||||||
|
echo "==============================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=()
|
||||||
|
local arch=""
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
detect_environment
|
||||||
|
install_dependencies
|
||||||
|
prepare_workspace
|
||||||
|
resolve_version
|
||||||
|
|
||||||
|
mapfile -t targets < <(select_targets)
|
||||||
|
|
||||||
|
for arch in "${targets[@]}"; do
|
||||||
|
build_one_target "$arch"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+741
-43
@@ -1,58 +1,756 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
Arch="$1"
|
VERSION_ARG=""
|
||||||
OutputPath="$2"
|
WITH_CORE="both"
|
||||||
Version="$3"
|
FORCE_NETCORE=0
|
||||||
|
ARCH_OVERRIDE=""
|
||||||
|
BUILD_FROM=""
|
||||||
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
FileName="v2rayN-${Arch}.zip"
|
MIN_KERNEL="6.12"
|
||||||
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
PKGROOT="v2rayN-publish"
|
||||||
7z x $FileName
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
cp -rf v2rayN-${Arch}/* $OutputPath
|
OUTPUT_DIR="${HOME}/debbuild"
|
||||||
|
|
||||||
PackagePath="v2rayN-Package-${Arch}"
|
OS_ID=""
|
||||||
mkdir -p "${PackagePath}/DEBIAN"
|
OS_NAME=""
|
||||||
mkdir -p "${PackagePath}/opt"
|
OS_VERSION_ID=""
|
||||||
cp -rf $OutputPath "${PackagePath}/opt/v2rayN"
|
HOST_ARCH=""
|
||||||
echo "When this file exists, app will not store configs under this folder" > "${PackagePath}/opt/v2rayN/NotStoreConfigHere.txt"
|
SCRIPT_DIR=""
|
||||||
|
PROJECT=""
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
if [ $Arch = "linux-64" ]; then
|
declare -a BUILT_DEBS=()
|
||||||
Arch2="amd64"
|
|
||||||
else
|
die() {
|
||||||
Arch2="arm64"
|
echo "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
||||||
|
VERSION_ARG="$first_arg"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
||||||
|
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
||||||
|
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
||||||
|
--netcore) FORCE_NETCORE=1; shift ;;
|
||||||
|
--arch) ARCH_OVERRIDE="${2:-}"; shift 2 ;;
|
||||||
|
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
||||||
|
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_environment() {
|
||||||
|
local current_kernel=""
|
||||||
|
local lowest=""
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
OS_ID="${ID:-}"
|
||||||
|
OS_NAME="${NAME:-$OS_ID}"
|
||||||
|
OS_VERSION_ID="${VERSION_ID:-}"
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS_ID" in
|
||||||
|
debian)
|
||||||
|
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
||||||
|
This script only supports: Debian."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
x86_64|aarch64) ;;
|
||||||
|
*) die "Only supports aarch64 / x86_64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
current_kernel="$(uname -r)"
|
||||||
|
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
||||||
|
|
||||||
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local install_ok=0
|
||||||
|
local foreign_arch=""
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get -y install \
|
||||||
|
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
|
||||||
|
desktop-file-utils xdg-utils wget
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
aarch64) foreign_arch="amd64" ;;
|
||||||
|
x86_64) foreign_arch="arm64" ;;
|
||||||
|
*) die "Only supports aarch64 / x86_64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
sudo dpkg --add-architecture "$foreign_arch" || true
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get -y install \
|
||||||
|
"libc6:${foreign_arch}" \
|
||||||
|
"libgcc-s1:${foreign_arch}" \
|
||||||
|
"libstdc++6:${foreign_arch}" \
|
||||||
|
"zlib1g:${foreign_arch}" \
|
||||||
|
"libfontconfig1:${foreign_arch}"
|
||||||
|
|
||||||
|
wget -q https://dot.net/v1/dotnet-install.sh
|
||||||
|
chmod +x dotnet-install.sh
|
||||||
|
./dotnet-install.sh --channel 10.0.1xx --install-dir "$HOME/.dotnet"
|
||||||
|
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
|
dotnet --info >/dev/null 2>&1 && install_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
|
echo "dotnet-sdk 10.x, curl, unzip, tar, rsync, git, dpkg-deb, desktop-file-utils, xdg-utils"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workspace() {
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$PROJECT_HINT"
|
||||||
|
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
||||||
|
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_channel() {
|
||||||
|
local ch="latest"
|
||||||
|
local sel=""
|
||||||
|
|
||||||
|
if [[ -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
case "$BUILD_FROM" in
|
||||||
|
1) echo "latest"; return 0 ;;
|
||||||
|
2) echo "prerelease"; return 0 ;;
|
||||||
|
3) echo "keep"; return 0 ;;
|
||||||
|
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
echo "[?] Choose v2rayN release channel:" >&2
|
||||||
|
echo " 1) Latest (stable) [default]" >&2
|
||||||
|
echo " 2) Pre-release (preview)" >&2
|
||||||
|
echo " 3) Keep current (do nothing)" >&2
|
||||||
|
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
||||||
|
|
||||||
|
if read -r sel </dev/tty; then
|
||||||
|
case "${sel:-}" in
|
||||||
|
2) ch="prerelease" ;;
|
||||||
|
3) ch="keep" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ch"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||||
|
| jq -re '.tag_name' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_prerelease() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
||||||
|
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_submodules() {
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git_try_checkout() {
|
||||||
|
local want="$1"
|
||||||
|
local ref=""
|
||||||
|
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
git fetch --tags --force --prune --depth=1 || true
|
||||||
|
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
||||||
|
|
||||||
|
if [[ -n "$ref" ]]; then
|
||||||
|
echo "[OK] Found ref '${ref}', checking out..."
|
||||||
|
git checkout -f "$ref"
|
||||||
|
sync_submodules
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_channel_or_keep() {
|
||||||
|
local ch="$1"
|
||||||
|
local tag=""
|
||||||
|
|
||||||
|
if [[ "$ch" == "keep" ]]; then
|
||||||
|
echo "[*] Keep current repository state (no checkout)."
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
||||||
|
|
||||||
|
case "$ch" in
|
||||||
|
latest) tag="$(get_latest_tag_latest || true)" ;;
|
||||||
|
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
||||||
|
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
||||||
|
|
||||||
|
echo "[*] Latest tag for '${ch}': ${tag}"
|
||||||
|
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
||||||
|
VERSION="${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
if [[ -n "${VERSION_ARG:-}" ]]; then
|
||||||
|
local clean_ver="${VERSION_ARG#v}"
|
||||||
|
|
||||||
|
if git_try_checkout "$clean_ver"; then
|
||||||
|
VERSION="$clean_ver"
|
||||||
|
else
|
||||||
|
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current directory is not a git repo; proceeding on current tree."
|
||||||
|
VERSION="${VERSION_ARG:-0.0.0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
xray_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-x64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip" ;;
|
||||||
|
linux-arm64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
singbox_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-x64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz" ;;
|
||||||
|
linux-arm64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-x64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip" ;;
|
||||||
|
linux-arm64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
download_xray() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${XRAY_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_singbox() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${SING_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local bin=""
|
||||||
|
local cronet=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
|
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
unify_geo_layout() {
|
||||||
|
local outroot="$1"
|
||||||
|
local n
|
||||||
|
local names=(
|
||||||
|
geosite.dat
|
||||||
|
geoip.dat
|
||||||
|
geoip-only-cn-private.dat
|
||||||
|
Country.mmdb
|
||||||
|
geoip.metadb
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
||||||
|
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
download_geo_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local bin_dir="$outroot/bin"
|
||||||
|
local srss_dir="$bin_dir/srss"
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
|
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
|
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_zip_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local nested_dir=""
|
||||||
|
|
||||||
|
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$tmp/bin/" "$outroot/bin/"
|
||||||
|
else
|
||||||
|
rsync -a "$tmp/" "$outroot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
||||||
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
|
rm -rf "$nested_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "[+] Bundle extracted to $outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_netcore_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_runtime_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
|
echo "[*] Using v2rayN bundle bin assets."
|
||||||
|
else
|
||||||
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[*] --netcore specified: use separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_target() {
|
||||||
|
local short="$1"
|
||||||
|
|
||||||
|
case "$short" in
|
||||||
|
x64) printf '%s\n%s\n' "linux-x64" "amd64" ;;
|
||||||
|
arm64) printf '%s\n%s\n' "linux-arm64" "arm64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use x64|arm64)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_binary() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
dotnet clean "$PROJECT" -c Release
|
||||||
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
|
dotnet restore "$PROJECT"
|
||||||
|
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_launcher_file() {
|
||||||
|
local stage="$1"
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$stage/usr/bin/v2rayn" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="/opt/v2rayN"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
if [[ -x "$DIR/v2rayN" ]]; then
|
||||||
|
exec "$DIR/v2rayN" "$@"
|
||||||
fi
|
fi
|
||||||
echo $Arch2
|
|
||||||
|
|
||||||
# basic
|
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
||||||
cat >"${PackagePath}/DEBIAN/control" <<-EOF
|
if [[ -f "$DIR/$dll" ]]; then
|
||||||
Package: v2rayN
|
exec /usr/bin/dotnet "$DIR/$dll" "$@"
|
||||||
Version: $Version
|
fi
|
||||||
Architecture: $Arch2
|
done
|
||||||
Maintainer: https://github.com/2dust/v2rayN
|
|
||||||
Description: A GUI client for Windows and Linux, support Xray core and sing-box-core and others
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
|
ls -l "$DIR" >&2 || true
|
||||||
|
exit 1
|
||||||
EOF
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
cat >"${PackagePath}/DEBIAN/postinst" <<-EOF
|
write_desktop_file() {
|
||||||
if [ ! -s /usr/share/applications/v2rayN.desktop ]; then
|
local stage="$1"
|
||||||
cat >/usr/share/applications/v2rayN.desktop<<-END
|
|
||||||
|
install -m 644 /dev/stdin "$stage/usr/share/applications/v2rayn.desktop" <<'EOF'
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=v2rayN
|
|
||||||
Comment=A GUI client for Windows and Linux, support Xray core and sing-box-core and others
|
|
||||||
Exec=/opt/v2rayN/v2rayN
|
|
||||||
Icon=/opt/v2rayN/v2rayN.png
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Network;Application;
|
Name=v2rayN
|
||||||
END
|
Comment=v2rayN for Debian GNU Linux
|
||||||
fi
|
Exec=v2rayn
|
||||||
|
Icon=v2rayn
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
update-desktop-database
|
write_maintainer_scripts() {
|
||||||
|
local debian_dir="$1"
|
||||||
|
|
||||||
|
install -m 755 /dev/stdin "$debian_dir/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
sudo chmod 0755 "${PackagePath}/DEBIAN/postinst"
|
install -m 755 /dev/stdin "$debian_dir/postrm" <<'EOF'
|
||||||
sudo chmod 0755 "${PackagePath}/opt/v2rayN/v2rayN"
|
#!/bin/sh
|
||||||
sudo chmod 0755 "${PackagePath}/opt/v2rayN/AmazTool"
|
set -e
|
||||||
|
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
# desktop && PATH
|
package_binary() {
|
||||||
|
local short="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local deb_arch="$3"
|
||||||
|
local pubdir=""
|
||||||
|
local workdir=""
|
||||||
|
local stage=""
|
||||||
|
local debian_dir=""
|
||||||
|
local project_dir=""
|
||||||
|
local icon_candidate=""
|
||||||
|
local shlibs_depends=""
|
||||||
|
local extra_depends=""
|
||||||
|
local final_depends=""
|
||||||
|
local multiarch=""
|
||||||
|
local sys_libdir=""
|
||||||
|
local sys_usrlibdir=""
|
||||||
|
local deb_out=""
|
||||||
|
|
||||||
sudo dpkg-deb -Zxz --build $PackagePath
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
sudo mv "${PackagePath}.deb" "v2rayN-${Arch}.deb"
|
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
|
|
||||||
|
stage="$workdir/${PKGROOT}_${VERSION}_${deb_arch}"
|
||||||
|
debian_dir="$stage/DEBIAN"
|
||||||
|
|
||||||
|
mkdir -p "$stage/opt/v2rayN" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/256x256/apps" "$debian_dir"
|
||||||
|
cp -a "$pubdir/." "$stage/opt/v2rayN/"
|
||||||
|
|
||||||
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
|
[[ -f "$icon_candidate" ]] && cp "$icon_candidate" "$stage/usr/share/icons/hicolor/256x256/apps/v2rayn.png" || true
|
||||||
|
|
||||||
|
stage_runtime_assets "$stage/opt/v2rayN" "$rid"
|
||||||
|
write_launcher_file "$stage"
|
||||||
|
write_desktop_file "$stage"
|
||||||
|
write_maintainer_scripts "$debian_dir"
|
||||||
|
|
||||||
|
extra_depends="libc6 (>= 2.39), fontconfig (>= 2.15.0), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 9.4), bash (>= 5.2.21), libfreetype6 (>= 2.13)"
|
||||||
|
|
||||||
|
mkdir -p "$workdir/debian"
|
||||||
|
cat > "$workdir/debian/control" <<EOF
|
||||||
|
Source: v2rayn
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: 2dust <noreply@github.com>
|
||||||
|
Standards-Version: 4.7.0
|
||||||
|
|
||||||
|
Package: v2rayn
|
||||||
|
Architecture: ${deb_arch}
|
||||||
|
Description: v2rayN
|
||||||
|
EOF
|
||||||
|
|
||||||
|
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
|
||||||
|
sys_libdir="/lib/$multiarch"
|
||||||
|
sys_usrlibdir="/usr/lib/$multiarch"
|
||||||
|
|
||||||
|
: > "$debian_dir/substvars"
|
||||||
|
|
||||||
|
mapfile -t ELF_FILES < <(
|
||||||
|
find "$stage/opt/v2rayN" -type f \( -name "*.so*" -o -perm -111 \) ! -name 'libcoreclrtraceptprovider.so'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${#ELF_FILES[@]}" -gt 0 ]]; then
|
||||||
|
(
|
||||||
|
cd "$workdir"
|
||||||
|
dpkg-shlibdeps \
|
||||||
|
-l"$stage/opt/v2rayN" \
|
||||||
|
-l"$sys_libdir" \
|
||||||
|
-l"$sys_usrlibdir" \
|
||||||
|
-T"$debian_dir/substvars" \
|
||||||
|
"${ELF_FILES[@]}"
|
||||||
|
) >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
shlibs_depends="$(sed -n 's/^shlibs:Depends=//p' "$debian_dir/substvars" | head -n1 || true)"
|
||||||
|
|
||||||
|
if [[ -n "$shlibs_depends" ]]; then
|
||||||
|
shlibs_depends="$(echo "$shlibs_depends" \
|
||||||
|
| sed -E 's/ *\([^)]*\)//g' \
|
||||||
|
| sed -E 's/ *, */, /g' \
|
||||||
|
| sed -E 's/^, *//; s/, *$//')"
|
||||||
|
final_depends="${shlibs_depends}, ${extra_depends}"
|
||||||
|
else
|
||||||
|
final_depends="${extra_depends}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$debian_dir/control" <<EOF
|
||||||
|
Package: v2rayn
|
||||||
|
Version: ${VERSION}
|
||||||
|
Architecture: ${deb_arch}
|
||||||
|
Maintainer: 2dust <noreply@github.com>
|
||||||
|
Homepage: https://github.com/2dust/v2rayN
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Depends: ${final_depends}
|
||||||
|
Description: v2rayN (Avalonia) GUI client for Linux
|
||||||
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
|
||||||
|
Shadowsocks / tuic / WireGuard.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
find "$stage/opt/v2rayN" -type d -exec chmod 0755 {} +
|
||||||
|
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
|
||||||
|
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
|
||||||
|
|
||||||
|
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
|
||||||
|
dpkg-deb --root-owner-group --build "$stage" "$deb_out"
|
||||||
|
|
||||||
|
echo "Build done for $short. DEB at:"
|
||||||
|
echo " $deb_out"
|
||||||
|
BUILT_DEBS+=("$deb_out")
|
||||||
|
}
|
||||||
|
|
||||||
|
select_targets() {
|
||||||
|
case "${ARCH_OVERRIDE:-}" in
|
||||||
|
all) printf '%s\n' x64 arm64 ;;
|
||||||
|
x64|amd64) printf '%s\n' x64 ;;
|
||||||
|
arm64|aarch64) printf '%s\n' arm64 ;;
|
||||||
|
"")
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
x86_64) printf '%s\n' x64 ;;
|
||||||
|
aarch64) printf '%s\n' arm64 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all." >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one_target() {
|
||||||
|
local short="$1"
|
||||||
|
local meta=()
|
||||||
|
local rid=""
|
||||||
|
local deb_arch=""
|
||||||
|
|
||||||
|
mapfile -t meta < <(describe_target "$short") || return 1
|
||||||
|
rid="${meta[0]}"
|
||||||
|
deb_arch="${meta[1]}"
|
||||||
|
|
||||||
|
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
|
||||||
|
publish_binary "$rid"
|
||||||
|
package_binary "$short" "$rid" "$deb_arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
local pkg=""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Build Summary ================="
|
||||||
|
if [[ "${#BUILT_DEBS[@]}" -gt 0 ]]; then
|
||||||
|
echo "Output directory: $OUTPUT_DIR"
|
||||||
|
for pkg in "${BUILT_DEBS[@]}"; do
|
||||||
|
echo "$pkg"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No DEBs detected in summary (check build logs above)."
|
||||||
|
fi
|
||||||
|
echo "==============================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=()
|
||||||
|
local arch=""
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
detect_environment
|
||||||
|
install_dependencies
|
||||||
|
prepare_workspace
|
||||||
|
resolve_version
|
||||||
|
|
||||||
|
mapfile -t targets < <(select_targets)
|
||||||
|
|
||||||
|
for arch in "${targets[@]}"; do
|
||||||
|
build_one_target "$arch"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
|
|||||||
+13
-1
@@ -22,7 +22,17 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>English</string>
|
<string>en</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>zh-Hans</string>
|
||||||
|
<string>zh-Hant</string>
|
||||||
|
<string>en</string>
|
||||||
|
<string>fa</string>
|
||||||
|
<string>fr</string>
|
||||||
|
<string>ru</string>
|
||||||
|
<string>hu</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>v2rayN</string>
|
<string>v2rayN</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
@@ -43,6 +53,8 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.6</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
Arch="$1"
|
|
||||||
OutputPath="$2"
|
|
||||||
|
|
||||||
OutputArch="v2rayN-${Arch}"
|
|
||||||
FileName="v2rayN-${Arch}.zip"
|
|
||||||
|
|
||||||
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
|
|
||||||
|
|
||||||
ZipPath64="./$OutputArch"
|
|
||||||
mkdir $ZipPath64
|
|
||||||
|
|
||||||
cp -rf $OutputPath "$ZipPath64/$OutputArch"
|
|
||||||
7z a -tZip $FileName "$ZipPath64/$OutputArch" -mx1
|
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_ARG=""
|
||||||
|
WITH_CORE="both"
|
||||||
|
FORCE_NETCORE=0
|
||||||
|
BUILD_FROM=""
|
||||||
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
|
MIN_KERNEL="6.12"
|
||||||
|
PKGROOT="v2rayN-publish"
|
||||||
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
|
RPM_TOPDIR="${HOME}/rpmbuild"
|
||||||
|
DOTNET_LOONGARCH_VERSION="10.0.111"
|
||||||
|
DOTNET_LOONGARCH_TAG="v10.0.111-loongarch64"
|
||||||
|
DOTNET_LOONGARCH_BASE="https://github.com/loongson/dotnet/releases/download"
|
||||||
|
DOTNET_LOONGARCH_FILE="dotnet-sdk-${DOTNET_LOONGARCH_VERSION}-linux-loongarch64.tar.gz"
|
||||||
|
DOTNET_SDK_URL="${DOTNET_LOONGARCH_BASE}/${DOTNET_LOONGARCH_TAG}/${DOTNET_LOONGARCH_FILE}"
|
||||||
|
|
||||||
|
OS_ID=""
|
||||||
|
OS_NAME=""
|
||||||
|
OS_VERSION_ID=""
|
||||||
|
HOST_ARCH=""
|
||||||
|
SCRIPT_DIR=""
|
||||||
|
PROJECT=""
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
|
declare -a BUILT_RPMS=()
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
||||||
|
VERSION_ARG="$first_arg"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
||||||
|
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
||||||
|
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
||||||
|
--netcore) FORCE_NETCORE=1; shift ;;
|
||||||
|
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
||||||
|
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_environment() {
|
||||||
|
local current_kernel=""
|
||||||
|
local lowest=""
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
OS_ID="${ID:-}"
|
||||||
|
OS_NAME="${NAME:-$OS_ID}"
|
||||||
|
OS_VERSION_ID="${VERSION_ID:-}"
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS_ID" in
|
||||||
|
rhel|rocky|almalinux|fedora|centos)
|
||||||
|
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
||||||
|
This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
loongarch64) ;;
|
||||||
|
*) die "Only supports loongarch64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
current_kernel="$(uname -r)"
|
||||||
|
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
||||||
|
|
||||||
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local install_ok=0
|
||||||
|
local tmp_dotnet=""
|
||||||
|
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
sudo dnf -y --nogpgcheck install \
|
||||||
|
rpm-build rpmdevtools curl unzip tar jq rsync git python3 \
|
||||||
|
glibc-devel kernel-headers libatomic file ca-certificates libicu \
|
||||||
|
&& install_ok=1
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.dotnet"
|
||||||
|
tmp_dotnet="$(mktemp -d)"
|
||||||
|
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
||||||
|
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_LOONGARCH_FILE"
|
||||||
|
rm -rf "$tmp_dotnet"
|
||||||
|
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
|
dotnet --info >/dev/null 2>&1 || install_ok=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
|
echo "dotnet-loongarch SDK, curl, unzip, tar, rsync, git, python3, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workspace() {
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$PROJECT_HINT"
|
||||||
|
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
||||||
|
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_channel() {
|
||||||
|
local ch="latest"
|
||||||
|
local sel=""
|
||||||
|
|
||||||
|
if [[ -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
case "$BUILD_FROM" in
|
||||||
|
1) echo "latest"; return 0 ;;
|
||||||
|
2) echo "prerelease"; return 0 ;;
|
||||||
|
3) echo "keep"; return 0 ;;
|
||||||
|
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
echo "[?] Choose v2rayN release channel:" >&2
|
||||||
|
echo " 1) Latest (stable) [default]" >&2
|
||||||
|
echo " 2) Pre-release (preview)" >&2
|
||||||
|
echo " 3) Keep current (do nothing)" >&2
|
||||||
|
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
||||||
|
|
||||||
|
if read -r sel </dev/tty; then
|
||||||
|
case "${sel:-}" in
|
||||||
|
2) ch="prerelease" ;;
|
||||||
|
3) ch="keep" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ch"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||||
|
| jq -re '.tag_name' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_prerelease() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
||||||
|
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_submodules() {
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git_try_checkout() {
|
||||||
|
local want="$1"
|
||||||
|
local ref=""
|
||||||
|
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
git fetch --tags --force --prune --depth=1 || true
|
||||||
|
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
||||||
|
|
||||||
|
if [[ -n "$ref" ]]; then
|
||||||
|
echo "[OK] Found ref '${ref}', checking out..."
|
||||||
|
git checkout -f "$ref"
|
||||||
|
sync_submodules
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_channel_or_keep() {
|
||||||
|
local ch="$1"
|
||||||
|
local tag=""
|
||||||
|
|
||||||
|
if [[ "$ch" == "keep" ]]; then
|
||||||
|
echo "[*] Keep current repository state (no checkout)."
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
||||||
|
|
||||||
|
case "$ch" in
|
||||||
|
latest) tag="$(get_latest_tag_latest || true)" ;;
|
||||||
|
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
||||||
|
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
||||||
|
|
||||||
|
echo "[*] Latest tag for '${ch}': ${tag}"
|
||||||
|
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
||||||
|
VERSION="${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
if [[ -n "${VERSION_ARG:-}" ]]; then
|
||||||
|
local clean_ver="${VERSION_ARG#v}"
|
||||||
|
|
||||||
|
if git_try_checkout "$clean_ver"; then
|
||||||
|
VERSION="$clean_ver"
|
||||||
|
else
|
||||||
|
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current directory is not a git repo; proceeding on current tree."
|
||||||
|
VERSION="${VERSION_ARG:-0.0.0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
xray_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-loongarch64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-loong64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
singbox_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-loongarch64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-loong64.tar.gz" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-loongarch64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-loong64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
download_xray() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${XRAY_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_singbox() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${SING_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local bin=""
|
||||||
|
local cronet=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
|
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
unify_geo_layout() {
|
||||||
|
local outroot="$1"
|
||||||
|
local n
|
||||||
|
local names=(
|
||||||
|
geosite.dat
|
||||||
|
geoip.dat
|
||||||
|
geoip-only-cn-private.dat
|
||||||
|
Country.mmdb
|
||||||
|
geoip.metadb
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
||||||
|
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
download_geo_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local bin_dir="$outroot/bin"
|
||||||
|
local srss_dir="$bin_dir/srss"
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
|
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
|
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_zip_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local nested_dir=""
|
||||||
|
|
||||||
|
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$tmp/bin/" "$outroot/bin/"
|
||||||
|
else
|
||||||
|
rsync -a "$tmp/" "$outroot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
||||||
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
|
rm -rf "$nested_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "[+] Bundle extracted to $outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_netcore_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_runtime_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
|
echo "[*] Using v2rayN bundle archive."
|
||||||
|
else
|
||||||
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[*] --netcore specified: use separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_target() {
|
||||||
|
local short="$1"
|
||||||
|
|
||||||
|
case "$short" in
|
||||||
|
loongarch64) printf '%s\n%s\n%s\n' "linux-loongarch64" "loongarch64" "loongarch64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use loongarch64)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_binary() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
dotnet clean "$PROJECT" -c Release
|
||||||
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
|
dotnet restore "$PROJECT"
|
||||||
|
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_spec_file() {
|
||||||
|
local specfile="$1"
|
||||||
|
|
||||||
|
cat > "$specfile" <<'SPEC'
|
||||||
|
%global debug_package %{nil}
|
||||||
|
%undefine _debuginfo_subpackages
|
||||||
|
%undefine _debugsource_packages
|
||||||
|
%global __requires_exclude ^liblttng-ust\.so\..*$
|
||||||
|
|
||||||
|
Name: v2rayN
|
||||||
|
Version: __VERSION__
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: v2rayN (Avalonia) GUI client for Linux
|
||||||
|
License: GPL-3.0-only
|
||||||
|
URL: https://github.com/2dust/v2rayN
|
||||||
|
BugURL: https://github.com/2dust/v2rayN/issues
|
||||||
|
ExclusiveArch: loongarch64
|
||||||
|
Source0: __PKGROOT__.tar.gz
|
||||||
|
|
||||||
|
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
||||||
|
Requires: glibc >= 2.39
|
||||||
|
Requires: fontconfig >= 2.15.0
|
||||||
|
Requires: desktop-file-utils >= 0.26
|
||||||
|
Requires: xdg-utils >= 1.1.3
|
||||||
|
Requires: coreutils >= 9.4
|
||||||
|
Requires: bash >= 5.2.21
|
||||||
|
Requires: freetype >= 2.13
|
||||||
|
|
||||||
|
%description
|
||||||
|
v2rayN Linux for Red Hat Enterprise Linux
|
||||||
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
|
||||||
|
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
||||||
|
For more information, Please visit our website
|
||||||
|
https://github.com/2dust/v2rayN
|
||||||
|
|
||||||
|
%prep
|
||||||
|
%setup -q -n __PKGROOT__
|
||||||
|
|
||||||
|
%build
|
||||||
|
|
||||||
|
%install
|
||||||
|
install -dm0755 %{buildroot}/opt/v2rayN
|
||||||
|
cp -a * %{buildroot}/opt/v2rayN/
|
||||||
|
|
||||||
|
find %{buildroot}/opt/v2rayN -type d -exec chmod 0755 {} +
|
||||||
|
find %{buildroot}/opt/v2rayN -type f -exec chmod 0644 {} +
|
||||||
|
[ -f %{buildroot}/opt/v2rayN/v2rayN ] && chmod 0755 %{buildroot}/opt/v2rayN/v2rayN || :
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_bindir}
|
||||||
|
install -m0755 /dev/stdin %{buildroot}%{_bindir}/v2rayn << 'EOF'
|
||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="/opt/v2rayN"
|
||||||
|
|
||||||
|
if [[ -x "$DIR/v2rayN" ]]; then exec "$DIR/v2rayN" "$@"; fi
|
||||||
|
|
||||||
|
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
||||||
|
if [[ -f "$DIR/$dll" ]]; then exec /usr/bin/dotnet "$DIR/$dll" "$@"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
|
ls -l "$DIR" >&2 || true
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_datadir}/applications
|
||||||
|
install -m0644 /dev/stdin %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=v2rayN
|
||||||
|
Comment=v2rayN for Red Hat Enterprise Linux
|
||||||
|
Exec=v2rayn
|
||||||
|
Icon=v2rayn
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
|
||||||
|
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
||||||
|
|
||||||
|
%post
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
||||||
|
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%postun
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
||||||
|
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_bindir}/v2rayn
|
||||||
|
/opt/v2rayN
|
||||||
|
%{_datadir}/applications/v2rayn.desktop
|
||||||
|
%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
||||||
|
SPEC
|
||||||
|
|
||||||
|
sed -i "s/__VERSION__/${VERSION}/g" "$specfile"
|
||||||
|
sed -i "s/__PKGROOT__/${PKGROOT}/g" "$specfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_binary() {
|
||||||
|
local short="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local rpm_target="$3"
|
||||||
|
local archdir="$4"
|
||||||
|
local pubdir=""
|
||||||
|
local workdir=""
|
||||||
|
local specfile=""
|
||||||
|
local sourcedir=""
|
||||||
|
local specdir=""
|
||||||
|
local project_dir=""
|
||||||
|
local icon_candidate=""
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
|
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
|
|
||||||
|
mkdir -p "$workdir/$PKGROOT"
|
||||||
|
cp -a "$pubdir/." "$workdir/$PKGROOT/"
|
||||||
|
|
||||||
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
|
[[ -f "$icon_candidate" ]] || { echo "Required icon not found: $icon_candidate"; return 1; }
|
||||||
|
cp "$icon_candidate" "$workdir/$PKGROOT/v2rayn.png"
|
||||||
|
|
||||||
|
stage_runtime_assets "$workdir/$PKGROOT" "$rid"
|
||||||
|
|
||||||
|
rpmdev-setuptree
|
||||||
|
sourcedir="${RPM_TOPDIR}/SOURCES"
|
||||||
|
specdir="${RPM_TOPDIR}/SPECS"
|
||||||
|
specfile="${specdir}/v2rayN.spec"
|
||||||
|
|
||||||
|
mkdir -p "$sourcedir" "$specdir"
|
||||||
|
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
||||||
|
|
||||||
|
write_spec_file "$specfile"
|
||||||
|
rpmbuild -ba "$specfile" --target "$rpm_target"
|
||||||
|
|
||||||
|
echo "Build done for $short. RPM at:"
|
||||||
|
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
echo " $f"
|
||||||
|
BUILT_RPMS+=("$f")
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
select_targets() {
|
||||||
|
printf '%s\n' loongarch64
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one_target() {
|
||||||
|
local short="$1"
|
||||||
|
local meta=()
|
||||||
|
local rid=""
|
||||||
|
local rpm_target=""
|
||||||
|
local archdir=""
|
||||||
|
|
||||||
|
mapfile -t meta < <(describe_target "$short") || return 1
|
||||||
|
rid="${meta[0]}"
|
||||||
|
rpm_target="${meta[1]}"
|
||||||
|
archdir="${meta[2]}"
|
||||||
|
|
||||||
|
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
||||||
|
publish_binary "$rid"
|
||||||
|
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
local rp=""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Build Summary ================"
|
||||||
|
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
||||||
|
for rp in "${BUILT_RPMS[@]}"; do
|
||||||
|
echo "$rp"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No RPMs detected in summary (check build logs above)."
|
||||||
|
fi
|
||||||
|
echo "=============================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=()
|
||||||
|
local arch=""
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
detect_environment
|
||||||
|
install_dependencies
|
||||||
|
prepare_workspace
|
||||||
|
resolve_version
|
||||||
|
|
||||||
|
mapfile -t targets < <(select_targets)
|
||||||
|
|
||||||
|
for arch in "${targets[@]}"; do
|
||||||
|
build_one_target "$arch"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_ARG=""
|
||||||
|
WITH_CORE="both"
|
||||||
|
FORCE_NETCORE=0
|
||||||
|
BUILD_FROM=""
|
||||||
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
|
MIN_KERNEL="5.10"
|
||||||
|
PKGROOT="v2rayN-publish"
|
||||||
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
|
RPM_TOPDIR="${HOME}/rpmbuild"
|
||||||
|
DOTNET_RISCV_VERSION="10.0.111"
|
||||||
|
DOTNET_RISCV_BASE="https://github.com/xujiegb/dotnet-riscv/releases/download"
|
||||||
|
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
|
||||||
|
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
|
||||||
|
|
||||||
|
OS_ID=""
|
||||||
|
OS_NAME=""
|
||||||
|
OS_VERSION_ID=""
|
||||||
|
HOST_ARCH=""
|
||||||
|
SCRIPT_DIR=""
|
||||||
|
PROJECT=""
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
|
declare -a BUILT_RPMS=()
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
||||||
|
VERSION_ARG="$first_arg"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
||||||
|
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
||||||
|
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
||||||
|
--netcore) FORCE_NETCORE=1; shift ;;
|
||||||
|
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
||||||
|
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_environment() {
|
||||||
|
local current_kernel=""
|
||||||
|
local lowest=""
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
OS_ID="${ID:-}"
|
||||||
|
OS_NAME="${NAME:-$OS_ID}"
|
||||||
|
OS_VERSION_ID="${VERSION_ID:-}"
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS_ID" in
|
||||||
|
rhel|rocky|almalinux|fedora|centos)
|
||||||
|
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
||||||
|
This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
riscv64) ;;
|
||||||
|
*) die "Only supports riscv64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
current_kernel="$(uname -r)"
|
||||||
|
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
||||||
|
|
||||||
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local install_ok=0
|
||||||
|
local tmp_dotnet=""
|
||||||
|
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
sudo dnf -y install \
|
||||||
|
rpm-build rpmdevtools curl unzip tar jq rsync git python3 \
|
||||||
|
glibc-devel kernel-headers libatomic file ca-certificates libicu \
|
||||||
|
&& install_ok=1
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.dotnet"
|
||||||
|
tmp_dotnet="$(mktemp -d)"
|
||||||
|
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
||||||
|
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_RISCV_FILE"
|
||||||
|
rm -rf "$tmp_dotnet"
|
||||||
|
|
||||||
|
export PATH="$HOME/.dotnet:$PATH"
|
||||||
|
export DOTNET_ROOT="$HOME/.dotnet"
|
||||||
|
|
||||||
|
dotnet --info >/dev/null 2>&1 || install_ok=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
|
echo "dotnet-riscv SDK, curl, unzip, tar, rsync, git, python3, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workspace() {
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$PROJECT_HINT"
|
||||||
|
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
||||||
|
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_channel() {
|
||||||
|
local ch="latest"
|
||||||
|
local sel=""
|
||||||
|
|
||||||
|
if [[ -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
case "$BUILD_FROM" in
|
||||||
|
1) echo "latest"; return 0 ;;
|
||||||
|
2) echo "prerelease"; return 0 ;;
|
||||||
|
3) echo "keep"; return 0 ;;
|
||||||
|
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
echo "[?] Choose v2rayN release channel:" >&2
|
||||||
|
echo " 1) Latest (stable) [default]" >&2
|
||||||
|
echo " 2) Pre-release (preview)" >&2
|
||||||
|
echo " 3) Keep current (do nothing)" >&2
|
||||||
|
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
||||||
|
|
||||||
|
if read -r sel </dev/tty; then
|
||||||
|
case "${sel:-}" in
|
||||||
|
2) ch="prerelease" ;;
|
||||||
|
3) ch="keep" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ch"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||||
|
| jq -re '.tag_name' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_prerelease() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
||||||
|
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_submodules() {
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git_try_checkout() {
|
||||||
|
local want="$1"
|
||||||
|
local ref=""
|
||||||
|
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
git fetch --tags --force --prune --depth=1 || true
|
||||||
|
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
||||||
|
|
||||||
|
if [[ -n "$ref" ]]; then
|
||||||
|
echo "[OK] Found ref '${ref}', checking out..."
|
||||||
|
git checkout -f "$ref"
|
||||||
|
sync_submodules
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_channel_or_keep() {
|
||||||
|
local ch="$1"
|
||||||
|
local tag=""
|
||||||
|
|
||||||
|
if [[ "$ch" == "keep" ]]; then
|
||||||
|
echo "[*] Keep current repository state (no checkout)."
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
||||||
|
|
||||||
|
case "$ch" in
|
||||||
|
latest) tag="$(get_latest_tag_latest || true)" ;;
|
||||||
|
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
||||||
|
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
||||||
|
|
||||||
|
echo "[*] Latest tag for '${ch}': ${tag}"
|
||||||
|
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
||||||
|
VERSION="${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
if [[ -n "${VERSION_ARG:-}" ]]; then
|
||||||
|
local clean_ver="${VERSION_ARG#v}"
|
||||||
|
|
||||||
|
if git_try_checkout "$clean_ver"; then
|
||||||
|
VERSION="$clean_ver"
|
||||||
|
else
|
||||||
|
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current directory is not a git repo; proceeding on current tree."
|
||||||
|
VERSION="${VERSION_ARG:-0.0.0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
xray_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-riscv64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
singbox_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-riscv64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-riscv64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
download_xray() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${XRAY_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_singbox() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${SING_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local bin=""
|
||||||
|
local cronet=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
|
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
unify_geo_layout() {
|
||||||
|
local outroot="$1"
|
||||||
|
local n
|
||||||
|
local names=(
|
||||||
|
geosite.dat
|
||||||
|
geoip.dat
|
||||||
|
geoip-only-cn-private.dat
|
||||||
|
Country.mmdb
|
||||||
|
geoip.metadb
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
||||||
|
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
download_geo_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local bin_dir="$outroot/bin"
|
||||||
|
local srss_dir="$bin_dir/srss"
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
|
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
|
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_zip_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local nested_dir=""
|
||||||
|
|
||||||
|
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$tmp/bin/" "$outroot/bin/"
|
||||||
|
else
|
||||||
|
rsync -a "$tmp/" "$outroot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
||||||
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
|
rm -rf "$nested_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "[+] Bundle extracted to $outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_netcore_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_runtime_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
|
echo "[*] Using v2rayN bundle archive."
|
||||||
|
else
|
||||||
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[*] --netcore specified: use separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_target() {
|
||||||
|
local short="$1"
|
||||||
|
|
||||||
|
case "$short" in
|
||||||
|
riscv64) printf '%s\n%s\n%s\n' "linux-riscv64" "riscv64" "riscv64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use riscv64)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_binary() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
dotnet clean "$PROJECT" -c Release
|
||||||
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
|
dotnet restore "$PROJECT"
|
||||||
|
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_spec_file() {
|
||||||
|
local specfile="$1"
|
||||||
|
|
||||||
|
cat > "$specfile" <<'SPEC'
|
||||||
|
%global debug_package %{nil}
|
||||||
|
%undefine _debuginfo_subpackages
|
||||||
|
%undefine _debugsource_packages
|
||||||
|
%global __requires_exclude ^liblttng-ust\.so\..*$
|
||||||
|
|
||||||
|
Name: v2rayN
|
||||||
|
Version: __VERSION__
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: v2rayN (Avalonia) GUI client for Linux
|
||||||
|
License: GPL-3.0-only
|
||||||
|
URL: https://github.com/2dust/v2rayN
|
||||||
|
BugURL: https://github.com/2dust/v2rayN/issues
|
||||||
|
ExclusiveArch: riscv64
|
||||||
|
Source0: __PKGROOT__.tar.gz
|
||||||
|
|
||||||
|
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
||||||
|
Requires: glibc >= 2.39
|
||||||
|
Requires: fontconfig >= 2.15.0
|
||||||
|
Requires: desktop-file-utils >= 0.26
|
||||||
|
Requires: xdg-utils >= 1.1.3
|
||||||
|
Requires: coreutils >= 9.4
|
||||||
|
Requires: bash >= 5.2.21
|
||||||
|
Requires: freetype >= 2.13
|
||||||
|
|
||||||
|
%description
|
||||||
|
v2rayN Linux for Red Hat Enterprise Linux
|
||||||
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
|
||||||
|
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
||||||
|
For more information, Please visit our website
|
||||||
|
https://github.com/2dust/v2rayN
|
||||||
|
|
||||||
|
%prep
|
||||||
|
%setup -q -n __PKGROOT__
|
||||||
|
|
||||||
|
%build
|
||||||
|
|
||||||
|
%install
|
||||||
|
install -dm0755 %{buildroot}/opt/v2rayN
|
||||||
|
cp -a * %{buildroot}/opt/v2rayN/
|
||||||
|
|
||||||
|
find %{buildroot}/opt/v2rayN -type d -exec chmod 0755 {} +
|
||||||
|
find %{buildroot}/opt/v2rayN -type f -exec chmod 0644 {} +
|
||||||
|
[ -f %{buildroot}/opt/v2rayN/v2rayN ] && chmod 0755 %{buildroot}/opt/v2rayN/v2rayN || :
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_bindir}
|
||||||
|
install -m0755 /dev/stdin %{buildroot}%{_bindir}/v2rayn << 'EOF'
|
||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="/opt/v2rayN"
|
||||||
|
|
||||||
|
if [[ -x "$DIR/v2rayN" ]]; then exec "$DIR/v2rayN" "$@"; fi
|
||||||
|
|
||||||
|
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
||||||
|
if [[ -f "$DIR/$dll" ]]; then exec /usr/bin/dotnet "$DIR/$dll" "$@"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
|
ls -l "$DIR" >&2 || true
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_datadir}/applications
|
||||||
|
install -m0644 /dev/stdin %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=v2rayN
|
||||||
|
Comment=v2rayN for Red Hat Enterprise Linux
|
||||||
|
Exec=v2rayn
|
||||||
|
Icon=v2rayn
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
|
||||||
|
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
||||||
|
|
||||||
|
%post
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
||||||
|
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%postun
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
||||||
|
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_bindir}/v2rayn
|
||||||
|
/opt/v2rayN
|
||||||
|
%{_datadir}/applications/v2rayn.desktop
|
||||||
|
%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
||||||
|
SPEC
|
||||||
|
|
||||||
|
sed -i "s/__VERSION__/${VERSION}/g" "$specfile"
|
||||||
|
sed -i "s/__PKGROOT__/${PKGROOT}/g" "$specfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_binary() {
|
||||||
|
local short="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local rpm_target="$3"
|
||||||
|
local archdir="$4"
|
||||||
|
local pubdir=""
|
||||||
|
local workdir=""
|
||||||
|
local specfile=""
|
||||||
|
local sourcedir=""
|
||||||
|
local specdir=""
|
||||||
|
local project_dir=""
|
||||||
|
local icon_candidate=""
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
|
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
|
|
||||||
|
mkdir -p "$workdir/$PKGROOT"
|
||||||
|
cp -a "$pubdir/." "$workdir/$PKGROOT/"
|
||||||
|
|
||||||
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
|
[[ -f "$icon_candidate" ]] || { echo "Required icon not found: $icon_candidate"; return 1; }
|
||||||
|
cp "$icon_candidate" "$workdir/$PKGROOT/v2rayn.png"
|
||||||
|
|
||||||
|
stage_runtime_assets "$workdir/$PKGROOT" "$rid"
|
||||||
|
|
||||||
|
rpmdev-setuptree
|
||||||
|
sourcedir="${RPM_TOPDIR}/SOURCES"
|
||||||
|
specdir="${RPM_TOPDIR}/SPECS"
|
||||||
|
specfile="${specdir}/v2rayN.spec"
|
||||||
|
|
||||||
|
mkdir -p "$sourcedir" "$specdir"
|
||||||
|
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
||||||
|
|
||||||
|
write_spec_file "$specfile"
|
||||||
|
rpmbuild -ba "$specfile" --target "$rpm_target"
|
||||||
|
|
||||||
|
echo "Build done for $short. RPM at:"
|
||||||
|
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
echo " $f"
|
||||||
|
BUILT_RPMS+=("$f")
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
select_targets() {
|
||||||
|
printf '%s\n' riscv64
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one_target() {
|
||||||
|
local short="$1"
|
||||||
|
local meta=()
|
||||||
|
local rid=""
|
||||||
|
local rpm_target=""
|
||||||
|
local archdir=""
|
||||||
|
|
||||||
|
mapfile -t meta < <(describe_target "$short") || return 1
|
||||||
|
rid="${meta[0]}"
|
||||||
|
rpm_target="${meta[1]}"
|
||||||
|
archdir="${meta[2]}"
|
||||||
|
|
||||||
|
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
||||||
|
publish_binary "$rid"
|
||||||
|
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
local rp=""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================ Build Summary ================"
|
||||||
|
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
||||||
|
for rp in "${BUILT_RPMS[@]}"; do
|
||||||
|
echo "$rp"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No RPMs detected in summary (check build logs above)."
|
||||||
|
fi
|
||||||
|
echo "=============================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=()
|
||||||
|
local arch=""
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
detect_environment
|
||||||
|
install_dependencies
|
||||||
|
prepare_workspace
|
||||||
|
resolve_version
|
||||||
|
|
||||||
|
mapfile -t targets < <(select_targets)
|
||||||
|
|
||||||
|
for arch in "${targets[@]}"; do
|
||||||
|
build_one_target "$arch"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+701
@@ -0,0 +1,701 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_ARG=""
|
||||||
|
WITH_CORE="both"
|
||||||
|
FORCE_NETCORE=0
|
||||||
|
ARCH_OVERRIDE=""
|
||||||
|
BUILD_FROM=""
|
||||||
|
XRAY_VER="${XRAY_VER:-}"
|
||||||
|
SING_VER="${SING_VER:-}"
|
||||||
|
|
||||||
|
MIN_KERNEL="6.12"
|
||||||
|
PKGROOT="v2rayN-publish"
|
||||||
|
PROJECT_HINT="v2rayN.Desktop/v2rayN.Desktop.csproj"
|
||||||
|
RPM_TOPDIR="${HOME}/rpmbuild"
|
||||||
|
|
||||||
|
OS_ID=""
|
||||||
|
OS_NAME=""
|
||||||
|
OS_VERSION_ID=""
|
||||||
|
HOST_ARCH=""
|
||||||
|
SCRIPT_DIR=""
|
||||||
|
PROJECT=""
|
||||||
|
VERSION=""
|
||||||
|
BUILT_ALL=0
|
||||||
|
|
||||||
|
declare -a BUILT_RPMS=()
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
local first_arg="${1:-}"
|
||||||
|
|
||||||
|
if [[ -n "$first_arg" && "$first_arg" != --* ]]; then
|
||||||
|
VERSION_ARG="$first_arg"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--with-core) WITH_CORE="${2:-both}"; shift 2 ;;
|
||||||
|
--xray-ver) XRAY_VER="${2:-}"; shift 2 ;;
|
||||||
|
--singbox-ver) SING_VER="${2:-}"; shift 2 ;;
|
||||||
|
--netcore) FORCE_NETCORE=1; shift ;;
|
||||||
|
--arch) ARCH_OVERRIDE="${2:-}"; shift 2 ;;
|
||||||
|
--buildfrom) BUILD_FROM="${2:-}"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
[[ -n "${VERSION_ARG:-}" ]] || VERSION_ARG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
die "You cannot specify both an explicit version and --buildfrom at the same time.
|
||||||
|
Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_environment() {
|
||||||
|
local current_kernel=""
|
||||||
|
local lowest=""
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
OS_ID="${ID:-}"
|
||||||
|
OS_NAME="${NAME:-$OS_ID}"
|
||||||
|
OS_VERSION_ID="${VERSION_ID:-}"
|
||||||
|
HOST_ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$OS_ID" in
|
||||||
|
rhel|rocky|almalinux|fedora|centos)
|
||||||
|
echo "Detected supported system: ${OS_NAME:-$OS_ID} ${OS_VERSION_ID:-}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unsupported system: ${OS_NAME:-unknown} (${OS_ID:-unknown}).
|
||||||
|
This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
x86_64|aarch64) ;;
|
||||||
|
*) die "Only supports aarch64 / x86_64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
current_kernel="$(uname -r)"
|
||||||
|
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$current_kernel" | sort -V | head -n1)"
|
||||||
|
|
||||||
|
[[ "$lowest" == "$MIN_KERNEL" ]] || die "Kernel $current_kernel is below $MIN_KERNEL"
|
||||||
|
echo "[OK] Kernel $current_kernel verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local install_ok=0
|
||||||
|
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
sudo dnf -y install rpm-build rpmdevtools curl unzip tar jq rsync dotnet-sdk-10.0 \
|
||||||
|
&& install_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$install_ok" -ne 1 ]]; then
|
||||||
|
echo "Could not auto-install dependencies for '$OS_ID'. Make sure these are available:"
|
||||||
|
echo "dotnet-sdk 10.x, curl, unzip, tar, rsync, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_workspace() {
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT="$PROJECT_HINT"
|
||||||
|
[[ -f "$PROJECT" ]] || PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
|
||||||
|
[[ -f "$PROJECT" ]] || die "v2rayN.Desktop.csproj not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_channel() {
|
||||||
|
local ch="latest"
|
||||||
|
local sel=""
|
||||||
|
|
||||||
|
if [[ -n "${BUILD_FROM:-}" ]]; then
|
||||||
|
case "$BUILD_FROM" in
|
||||||
|
1) echo "latest"; return 0 ;;
|
||||||
|
2) echo "prerelease"; return 0 ;;
|
||||||
|
3) echo "keep"; return 0 ;;
|
||||||
|
*) die "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
echo "[?] Choose v2rayN release channel:" >&2
|
||||||
|
echo " 1) Latest (stable) [default]" >&2
|
||||||
|
echo " 2) Pre-release (preview)" >&2
|
||||||
|
echo " 3) Keep current (do nothing)" >&2
|
||||||
|
printf "Enter 1, 2 or 3 [default 1]: " >&2
|
||||||
|
|
||||||
|
if read -r sel </dev/tty; then
|
||||||
|
case "${sel:-}" in
|
||||||
|
2) ch="prerelease" ;;
|
||||||
|
3) ch="keep" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ch"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_latest() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
|
||||||
|
| jq -re '.tag_name' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_latest_tag_prerelease() {
|
||||||
|
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
|
||||||
|
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
|
||||||
|
| sed 's/^v//'
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_submodules() {
|
||||||
|
if [[ -f .gitmodules ]]; then
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
git_try_checkout() {
|
||||||
|
local want="$1"
|
||||||
|
local ref=""
|
||||||
|
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
git fetch --tags --force --prune --depth=1 || true
|
||||||
|
git rev-parse "refs/tags/${want}" >/dev/null 2>&1 && ref="$want"
|
||||||
|
|
||||||
|
if [[ -n "$ref" ]]; then
|
||||||
|
echo "[OK] Found ref '${ref}', checking out..."
|
||||||
|
git checkout -f "$ref"
|
||||||
|
sync_submodules
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_channel_or_keep() {
|
||||||
|
local ch="$1"
|
||||||
|
local tag=""
|
||||||
|
|
||||||
|
if [[ "$ch" == "keep" ]]; then
|
||||||
|
echo "[*] Keep current repository state (no checkout)."
|
||||||
|
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[*] Resolving ${ch} tag from GitHub releases..."
|
||||||
|
|
||||||
|
case "$ch" in
|
||||||
|
latest) tag="$(get_latest_tag_latest || true)" ;;
|
||||||
|
prerelease) tag="$(get_latest_tag_prerelease || true)" ;;
|
||||||
|
*) die "Failed to resolve latest tag for channel '${ch}'." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -n "$tag" ]] || die "Failed to resolve latest tag for channel '${ch}'."
|
||||||
|
|
||||||
|
echo "[*] Latest tag for '${ch}': ${tag}"
|
||||||
|
git_try_checkout "$tag" || die "Failed to checkout '${tag}'."
|
||||||
|
VERSION="${tag#v}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
if [[ -n "${VERSION_ARG:-}" ]]; then
|
||||||
|
local clean_ver="${VERSION_ARG#v}"
|
||||||
|
|
||||||
|
if git_try_checkout "$clean_ver"; then
|
||||||
|
VERSION="$clean_ver"
|
||||||
|
else
|
||||||
|
echo "[WARN] Tag '${VERSION_ARG}' not found."
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
apply_channel_or_keep "$(choose_channel)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Current directory is not a git repo; proceeding on current tree."
|
||||||
|
VERSION="${VERSION_ARG:-0.0.0}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
echo "[*] GUI version resolved as: ${VERSION}"
|
||||||
|
}
|
||||||
|
|
||||||
|
xray_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-x64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip" ;;
|
||||||
|
linux-arm64) echo "https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
singbox_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
local ver="$2"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-x64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz" ;;
|
||||||
|
linux-arm64) echo "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_url_for_rid() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
case "$rid" in
|
||||||
|
linux-x64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip" ;;
|
||||||
|
linux-arm64) echo "https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
download_xray() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${XRAY_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
|
||||||
|
url="$(xray_url_for_rid "$rid" "$ver")" || { echo "[xray] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download xray: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/xray.zip" || { rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/xray.zip" -d "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||||
|
install -m 755 "$tmp/xray" "$outdir/xray" || { rm -rf "$tmp"; return 1; }
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_singbox() {
|
||||||
|
local outdir="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local ver="${SING_VER:-}"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local bin=""
|
||||||
|
local cronet=""
|
||||||
|
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
if [[ -z "$ver" ]]; then
|
||||||
|
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
|
||||||
|
| grep -Eo '"tag_name":\s*"v[^"]+"' \
|
||||||
|
| sed -E 's/.*"v([^"]+)".*/\1/' \
|
||||||
|
| head -n1)" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
|
||||||
|
url="$(singbox_url_for_rid "$rid" "$ver")" || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Download sing-box: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
tar -C "$tmp" -xzf "$tmp/singbox.tar.gz" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
|
||||||
|
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
install -m 755 "$bin" "$outdir/sing-box" || { rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
|
||||||
|
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so" || true
|
||||||
|
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
unify_geo_layout() {
|
||||||
|
local outroot="$1"
|
||||||
|
local n
|
||||||
|
local names=(
|
||||||
|
geosite.dat
|
||||||
|
geoip.dat
|
||||||
|
geoip-only-cn-private.dat
|
||||||
|
Country.mmdb
|
||||||
|
geoip.metadb
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
|
||||||
|
for n in "${names[@]}"; do
|
||||||
|
if [[ -f "$outroot/bin/xray/$n" ]]; then
|
||||||
|
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
download_geo_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local bin_dir="$outroot/bin"
|
||||||
|
local srss_dir="$bin_dir/srss"
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
mkdir -p "$bin_dir" "$srss_dir"
|
||||||
|
|
||||||
|
echo "[+] Download Xray Geo to ${bin_dir}"
|
||||||
|
curl -fsSL -o "$bin_dir/geosite.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.dat" "https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
|
||||||
|
curl -fsSL -o "$bin_dir/Country.mmdb" "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
|
||||||
|
|
||||||
|
echo "[+] Download sing-box rule DB & rule-sets"
|
||||||
|
curl -fsSL -o "$bin_dir/geoip.metadb" "https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb"
|
||||||
|
|
||||||
|
for f in geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geoip/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
for f in geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
|
||||||
|
curl -fsSL -o "$srss_dir/$f" "https://raw.githubusercontent.com/2dust/sing-box-rules/refs/heads/rule-set-geosite/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_zip_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local url=""
|
||||||
|
local tmp=""
|
||||||
|
local nested_dir=""
|
||||||
|
|
||||||
|
url="$(bundle_url_for_rid "$rid")" || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
|
||||||
|
|
||||||
|
echo "[+] Try v2rayN bundle archive: $url"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fL "$url" -o "$tmp/v2rayn.zip" || { echo "[!] Bundle download failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
unzip -q "$tmp/v2rayn.zip" -d "$tmp" || { echo "[!] Bundle unzip failed"; rm -rf "$tmp"; return 1; }
|
||||||
|
|
||||||
|
if [[ -d "$tmp/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$tmp/bin/" "$outroot/bin/"
|
||||||
|
else
|
||||||
|
rsync -a "$tmp/" "$outroot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
|
||||||
|
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
|
||||||
|
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
|
||||||
|
mkdir -p "$outroot/bin"
|
||||||
|
rsync -a "$nested_dir/bin/" "$outroot/bin/"
|
||||||
|
rm -rf "$nested_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unify_geo_layout "$outroot"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
echo "[+] Bundle extracted to $outroot"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_assets_netcore_mode() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_xray "$outroot/bin/xray" "$rid" || echo "[!] xray download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
|
||||||
|
download_singbox "$outroot/bin/sing_box" "$rid" || echo "[!] sing-box download failed (skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage_runtime_assets() {
|
||||||
|
local outroot="$1"
|
||||||
|
local rid="$2"
|
||||||
|
|
||||||
|
mkdir -p "$outroot/bin/xray" "$outroot/bin/sing_box"
|
||||||
|
|
||||||
|
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
|
||||||
|
if populate_assets_zip_mode "$outroot" "$rid"; then
|
||||||
|
echo "[*] Using v2rayN bundle archive."
|
||||||
|
else
|
||||||
|
echo "[*] Bundle failed, fallback to separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[*] --netcore specified: use separate core + rules."
|
||||||
|
populate_assets_netcore_mode "$outroot" "$rid"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
describe_target() {
|
||||||
|
local short="$1"
|
||||||
|
|
||||||
|
case "$short" in
|
||||||
|
x64) printf '%s\n%s\n%s\n' "linux-x64" "x86_64" "x86_64" ;;
|
||||||
|
arm64) printf '%s\n%s\n%s\n' "linux-arm64" "aarch64" "aarch64" ;;
|
||||||
|
*) echo "Unknown arch '$short' (use x64|arm64)" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_binary() {
|
||||||
|
local rid="$1"
|
||||||
|
|
||||||
|
dotnet clean "$PROJECT" -c Release
|
||||||
|
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
|
||||||
|
dotnet restore "$PROJECT"
|
||||||
|
dotnet publish "$PROJECT" -c Release -r "$rid" -p:PublishSingleFile=false -p:SelfContained=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_spec_file() {
|
||||||
|
local specfile="$1"
|
||||||
|
|
||||||
|
cat > "$specfile" <<'SPEC'
|
||||||
|
%global debug_package %{nil}
|
||||||
|
%undefine _debuginfo_subpackages
|
||||||
|
%undefine _debugsource_packages
|
||||||
|
%global __requires_exclude ^liblttng-ust\.so\..*$
|
||||||
|
|
||||||
|
Name: v2rayN
|
||||||
|
Version: __VERSION__
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: v2rayN (Avalonia) GUI client for Linux
|
||||||
|
License: GPL-3.0-only
|
||||||
|
URL: https://github.com/2dust/v2rayN
|
||||||
|
BugURL: https://github.com/2dust/v2rayN/issues
|
||||||
|
ExclusiveArch: aarch64 x86_64
|
||||||
|
Source0: __PKGROOT__.tar.gz
|
||||||
|
|
||||||
|
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
|
||||||
|
Requires: glibc >= 2.39
|
||||||
|
Requires: fontconfig >= 2.15.0
|
||||||
|
Requires: desktop-file-utils >= 0.26
|
||||||
|
Requires: xdg-utils >= 1.1.3
|
||||||
|
Requires: coreutils >= 9.4
|
||||||
|
Requires: bash >= 5.2.21
|
||||||
|
Requires: freetype >= 2.13
|
||||||
|
|
||||||
|
%description
|
||||||
|
v2rayN Linux for Red Hat Enterprise Linux
|
||||||
|
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
|
||||||
|
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
|
||||||
|
For more information, Please visit our website
|
||||||
|
https://github.com/2dust/v2rayN
|
||||||
|
|
||||||
|
%prep
|
||||||
|
%setup -q -n __PKGROOT__
|
||||||
|
|
||||||
|
%build
|
||||||
|
|
||||||
|
%install
|
||||||
|
install -dm0755 %{buildroot}/opt/v2rayN
|
||||||
|
cp -a * %{buildroot}/opt/v2rayN/
|
||||||
|
|
||||||
|
find %{buildroot}/opt/v2rayN -type d -exec chmod 0755 {} +
|
||||||
|
find %{buildroot}/opt/v2rayN -type f -exec chmod 0644 {} +
|
||||||
|
[ -f %{buildroot}/opt/v2rayN/v2rayN ] && chmod 0755 %{buildroot}/opt/v2rayN/v2rayN || :
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_bindir}
|
||||||
|
install -m0755 /dev/stdin %{buildroot}%{_bindir}/v2rayn << 'EOF'
|
||||||
|
#!/usr/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="/opt/v2rayN"
|
||||||
|
|
||||||
|
if [[ -x "$DIR/v2rayN" ]]; then exec "$DIR/v2rayN" "$@"; fi
|
||||||
|
|
||||||
|
for dll in v2rayN.Desktop.dll v2rayN.dll; do
|
||||||
|
if [[ -f "$DIR/$dll" ]]; then exec /usr/bin/dotnet "$DIR/$dll" "$@"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "v2rayN launcher: no executable found in $DIR" >&2
|
||||||
|
ls -l "$DIR" >&2 || true
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_datadir}/applications
|
||||||
|
install -m0644 /dev/stdin %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=v2rayN
|
||||||
|
Comment=v2rayN for Red Hat Enterprise Linux
|
||||||
|
Exec=v2rayn
|
||||||
|
Icon=v2rayn
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
|
||||||
|
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
||||||
|
|
||||||
|
%post
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
||||||
|
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%postun
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
|
||||||
|
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_bindir}/v2rayn
|
||||||
|
/opt/v2rayN
|
||||||
|
%{_datadir}/applications/v2rayn.desktop
|
||||||
|
%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
|
||||||
|
SPEC
|
||||||
|
|
||||||
|
sed -i "s/__VERSION__/${VERSION}/g" "$specfile"
|
||||||
|
sed -i "s/__PKGROOT__/${PKGROOT}/g" "$specfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_binary() {
|
||||||
|
local short="$1"
|
||||||
|
local rid="$2"
|
||||||
|
local rpm_target="$3"
|
||||||
|
local archdir="$4"
|
||||||
|
local pubdir=""
|
||||||
|
local workdir=""
|
||||||
|
local specfile=""
|
||||||
|
local sourcedir=""
|
||||||
|
local specdir=""
|
||||||
|
local project_dir=""
|
||||||
|
local icon_candidate=""
|
||||||
|
local f=""
|
||||||
|
|
||||||
|
pubdir="$(dirname "$PROJECT")/bin/Release/net10.0/${rid}/publish"
|
||||||
|
[[ -d "$pubdir" ]] || { echo "Publish directory not found: $pubdir"; return 1; }
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap '[[ -n "${workdir:-}" ]] && rm -rf "$workdir"' RETURN
|
||||||
|
|
||||||
|
mkdir -p "$workdir/$PKGROOT"
|
||||||
|
cp -a "$pubdir/." "$workdir/$PKGROOT/"
|
||||||
|
|
||||||
|
project_dir="$(cd "$(dirname "$PROJECT")" && pwd)"
|
||||||
|
icon_candidate="$project_dir/v2rayN.png"
|
||||||
|
[[ -f "$icon_candidate" ]] || { echo "Required icon not found: $icon_candidate"; return 1; }
|
||||||
|
cp "$icon_candidate" "$workdir/$PKGROOT/v2rayn.png"
|
||||||
|
|
||||||
|
stage_runtime_assets "$workdir/$PKGROOT" "$rid"
|
||||||
|
|
||||||
|
rpmdev-setuptree
|
||||||
|
sourcedir="${RPM_TOPDIR}/SOURCES"
|
||||||
|
specdir="${RPM_TOPDIR}/SPECS"
|
||||||
|
specfile="${specdir}/v2rayN.spec"
|
||||||
|
|
||||||
|
mkdir -p "$sourcedir" "$specdir"
|
||||||
|
tar -C "$workdir" -czf "$sourcedir/$PKGROOT.tar.gz" "$PKGROOT"
|
||||||
|
|
||||||
|
write_spec_file "$specfile"
|
||||||
|
rpmbuild -ba "$specfile" --target "$rpm_target"
|
||||||
|
|
||||||
|
echo "Build done for $short. RPM at:"
|
||||||
|
for f in "${RPM_TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
echo " $f"
|
||||||
|
BUILT_RPMS+=("$f")
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
select_targets() {
|
||||||
|
case "${ARCH_OVERRIDE:-}" in
|
||||||
|
all) printf '%s\n' x64 arm64 ;;
|
||||||
|
x64|amd64) printf '%s\n' x64 ;;
|
||||||
|
arm64|aarch64) printf '%s\n' arm64 ;;
|
||||||
|
"")
|
||||||
|
case "$HOST_ARCH" in
|
||||||
|
x86_64) printf '%s\n' x64 ;;
|
||||||
|
aarch64) printf '%s\n' arm64 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all." >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
build_one_target() {
|
||||||
|
local short="$1"
|
||||||
|
local meta=()
|
||||||
|
local rid=""
|
||||||
|
local rpm_target=""
|
||||||
|
local archdir=""
|
||||||
|
|
||||||
|
mapfile -t meta < <(describe_target "$short") || return 1
|
||||||
|
rid="${meta[0]}"
|
||||||
|
rpm_target="${meta[1]}"
|
||||||
|
archdir="${meta[2]}"
|
||||||
|
|
||||||
|
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
|
||||||
|
publish_binary "$rid"
|
||||||
|
package_binary "$short" "$rid" "$rpm_target" "$archdir"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
if [[ "$BUILT_ALL" -eq 1 ]]; then
|
||||||
|
local rp=""
|
||||||
|
echo ""
|
||||||
|
echo "================ Build Summary (both architectures) ================"
|
||||||
|
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
|
||||||
|
for rp in "${BUILT_RPMS[@]}"; do
|
||||||
|
echo "$rp"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "No RPMs detected in summary (check build logs above)."
|
||||||
|
fi
|
||||||
|
echo "===================================================================="
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=()
|
||||||
|
local arch=""
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
detect_environment
|
||||||
|
install_dependencies
|
||||||
|
prepare_workspace
|
||||||
|
resolve_version
|
||||||
|
|
||||||
|
mapfile -t targets < <(select_targets)
|
||||||
|
[[ "${ARCH_OVERRIDE:-}" == "all" ]] && BUILT_ALL=1 || BUILT_ALL=0
|
||||||
|
|
||||||
|
for arch in "${targets[@]}"; do
|
||||||
|
build_one_target "$arch"
|
||||||
|
done
|
||||||
|
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
app: v2rayN
|
|
||||||
binpatch: true
|
|
||||||
|
|
||||||
ingredients:
|
|
||||||
script:
|
|
||||||
- export FileName="v2rayN-${AppImageOutputArch}.zip"
|
|
||||||
- wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/${FileName}"
|
|
||||||
- 7z x $FileName -aoa
|
|
||||||
- cp -rf v2rayN-${AppImageOutputArch}/* $OutputPath
|
|
||||||
|
|
||||||
script:
|
|
||||||
- mkdir -p usr/bin usr/lib
|
|
||||||
- cp -rf $OutputPath usr/lib/v2rayN
|
|
||||||
- echo "When this file exists, app will not store configs under this folder" > usr/lib/v2rayN/NotStoreConfigHere.txt
|
|
||||||
- ln -sf usr/lib/v2rayN/v2rayN usr/bin/v2rayN
|
|
||||||
- chmod a+x usr/lib/v2rayN/v2rayN
|
|
||||||
- find usr -type f -exec sh -c 'file "{}" | grep -qi "executable" && chmod +x "{}"' \;
|
|
||||||
- install -Dm644 usr/lib/v2rayN/v2rayN.png v2rayN.png
|
|
||||||
- install -Dm644 usr/lib/v2rayN/v2rayN.png usr/share/pixmaps/v2rayN.png
|
|
||||||
- cat > v2rayN.desktop <<EOF
|
|
||||||
- [Desktop Entry]
|
|
||||||
- Name=v2rayN
|
|
||||||
- Comment=A GUI client for Windows and Linux, support Xray core and sing-box-core and others
|
|
||||||
- Exec=v2rayN
|
|
||||||
- Icon=v2rayN
|
|
||||||
- Terminal=false
|
|
||||||
- Type=Application
|
|
||||||
- Categories=Network;
|
|
||||||
- EOF
|
|
||||||
- install -Dm644 v2rayN.desktop usr/share/applications/v2rayN.desktop
|
|
||||||
- cat > AppRun <<\EOF
|
|
||||||
- #!/bin/sh
|
|
||||||
- HERE="$(dirname "$(readlink -f "${0}")")"
|
|
||||||
- cd ${HERE}/usr/lib/v2rayN
|
|
||||||
- exec ${HERE}/usr/lib/v2rayN/v2rayN $@
|
|
||||||
- EOF
|
|
||||||
- chmod a+x AppRun
|
|
||||||
@@ -79,15 +79,7 @@ internal class UpgradeApp
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
TryExtractToFile(entry, entryOutputPath);
|
||||||
{
|
|
||||||
entry.ExtractToFile(entryOutputPath, true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
entry.ExtractToFile(entryOutputPath, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine(entryOutputPath);
|
Console.WriteLine(entryOutputPath);
|
||||||
}
|
}
|
||||||
@@ -113,4 +105,24 @@ internal class UpgradeApp
|
|||||||
|
|
||||||
Utils.StartV2RayN();
|
Utils.StartV2RayN();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryExtractToFile(ZipArchiveEntry entry, string outputPath)
|
||||||
|
{
|
||||||
|
var retryCount = 5;
|
||||||
|
var delayMs = 1000;
|
||||||
|
|
||||||
|
for (var i = 1; i <= retryCount; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry.ExtractToFile(outputPath, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Thread.Sleep(delayMs * i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>7.12.1</Version>
|
<Version>7.25.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
||||||
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
|
||||||
<NoWarn>CA1031;CS1591;NU1507;CA1416;IDE0058</NoWarn>
|
|
||||||
<Nullable>annotations</Nullable>
|
<Nullable>annotations</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Authors>2dust</Authors>
|
<Authors>2dust</Authors>
|
||||||
|
|||||||
@@ -5,26 +5,33 @@
|
|||||||
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
|
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.0" />
|
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.0" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.1.2" />
|
||||||
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.0" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="CliWrap" Version="3.8.2" />
|
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
|
||||||
<PackageVersion Include="Downloader" Version="3.3.4" />
|
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
|
||||||
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" />
|
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
|
||||||
<PackageVersion Include="MaterialDesignThemes" Version="5.2.1" />
|
<PackageVersion Include="CliWrap" Version="3.10.5" />
|
||||||
<PackageVersion Include="MessageBox.Avalonia" Version="3.2.0" />
|
<PackageVersion Include="Downloader" Version="5.9.6" />
|
||||||
<PackageVersion Include="QRCoder" Version="1.6.0" />
|
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
|
||||||
<PackageVersion Include="ReactiveUI" Version="20.2.45" />
|
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
|
||||||
<PackageVersion Include="ReactiveUI.Fody" Version="19.5.41" />
|
<PackageVersion Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageVersion Include="ReactiveUI.WPF" Version="20.2.45" />
|
<PackageVersion Include="ReactiveUI" Version="24.2.0" />
|
||||||
<PackageVersion Include="Semi.Avalonia" Version="11.2.1.7" />
|
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
|
||||||
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="11.2.1.7" />
|
<PackageVersion Include="ReactiveUI.WPF" Version="24.2.0" />
|
||||||
<PackageVersion Include="Splat.NLog" Version="15.3.1" />
|
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
||||||
<PackageVersion Include="sqlite-net-pcl" Version="1.9.172" />
|
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||||
<PackageVersion Include="TaskScheduler" Version="2.12.1" />
|
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
|
||||||
|
<PackageVersion Include="NLog" Version="6.2.0" />
|
||||||
|
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
|
||||||
|
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
|
||||||
|
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
|
||||||
|
<PackageVersion Include="TUnit" Version="1.66.27" />
|
||||||
|
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
|
||||||
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
|
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
||||||
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.14" />
|
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" />
|
||||||
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
+1
-1
Submodule v2rayN/GlobalHotKeys updated: ef73fa22c4...162d401dfe
@@ -0,0 +1,107 @@
|
|||||||
|
namespace ServiceLib.Tests.CoreConfig.Context;
|
||||||
|
|
||||||
|
public class CoreConfigContextBuilderTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var groupAId = NewId("group-a");
|
||||||
|
var groupBId = NewId("group-b");
|
||||||
|
var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId]);
|
||||||
|
var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupAId]);
|
||||||
|
|
||||||
|
await UpsertProfilesAsync(groupA, groupB);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap.Clear();
|
||||||
|
|
||||||
|
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
||||||
|
|
||||||
|
await validatorResult.Success.Should().BeFalse();
|
||||||
|
await validatorResult.Errors.Should().Contain(ContainsCycleDependencyMessage);
|
||||||
|
await context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
|
||||||
|
await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var groupAId = NewId("group-a");
|
||||||
|
var groupBId = NewId("group-b");
|
||||||
|
var groupCId = NewId("group-c");
|
||||||
|
var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId]);
|
||||||
|
var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupCId]);
|
||||||
|
var groupC = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupCId, "group-c", [groupAId]);
|
||||||
|
|
||||||
|
await UpsertProfilesAsync(groupA, groupB, groupC);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap.Clear();
|
||||||
|
|
||||||
|
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
||||||
|
|
||||||
|
await validatorResult.Success.Should().BeFalse();
|
||||||
|
await validatorResult.Errors.Should().Contain(ContainsCycleDependencyMessage);
|
||||||
|
await context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
|
||||||
|
await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
||||||
|
await context.AllProxiesMap.Should().NotContainKey(groupC.IndexId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig();
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var groupAId = NewId("group-a");
|
||||||
|
var groupBId = NewId("group-b");
|
||||||
|
var leafId = NewId("leaf");
|
||||||
|
var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId, leafId]);
|
||||||
|
var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupAId]);
|
||||||
|
var leaf = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, leafId, "leaf");
|
||||||
|
|
||||||
|
await UpsertProfilesAsync(groupA, groupB, leaf);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap.Clear();
|
||||||
|
|
||||||
|
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
|
||||||
|
|
||||||
|
await validatorResult.Success.Should().BeTrue();
|
||||||
|
await validatorResult.Errors.Should().BeEmpty();
|
||||||
|
await validatorResult.Warnings.Should().Contain(ContainsCycleDependencyMessage);
|
||||||
|
|
||||||
|
await context.AllProxiesMap.Should().ContainKey(leaf.IndexId);
|
||||||
|
await context.AllProxiesMap.Should().ContainKey(groupA.IndexId);
|
||||||
|
await context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
|
||||||
|
await groupA.GetProtocolExtra().ChildItems.Should().BeEqualTo(leaf.IndexId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewId(string prefix)
|
||||||
|
{
|
||||||
|
return $"{prefix}-{Guid.NewGuid():N}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsCycleDependencyMessage(string message)
|
||||||
|
{
|
||||||
|
return message.Contains("cycle dependency", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| message.Contains("循环依赖", StringComparison.Ordinal)
|
||||||
|
|| message.Contains("循環依賴", StringComparison.Ordinal)
|
||||||
|
|| message.Contains("циклическую зависимость", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpsertProfilesAsync(params ProfileItem[] profiles)
|
||||||
|
{
|
||||||
|
SQLiteHelper.Instance.CreateTable<ProfileItem>();
|
||||||
|
foreach (var profile in profiles)
|
||||||
|
{
|
||||||
|
await SQLiteHelper.Instance.ReplaceAsync(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ServiceLib.Enums;
|
||||||
|
using ServiceLib.Manager;
|
||||||
|
using ServiceLib.Models;
|
||||||
|
|
||||||
|
namespace ServiceLib.Tests.CoreConfig;
|
||||||
|
|
||||||
|
internal static class CoreConfigTestFactory
|
||||||
|
{
|
||||||
|
public static void BindAppManagerConfig(Config config)
|
||||||
|
{
|
||||||
|
var field = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
field?.SetValue(AppManager.Instance, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config CreateConfig(ECoreType vmessCoreType = ECoreType.Xray)
|
||||||
|
{
|
||||||
|
return new Config
|
||||||
|
{
|
||||||
|
CoreBasicItem = new CoreBasicItem { Loglevel = "warning" },
|
||||||
|
TunModeItem = new TunModeItem { EnableTun = false, IcmpRouting = "default" },
|
||||||
|
KcpItem = new KcpItem(),
|
||||||
|
GrpcItem = new GrpcItem(),
|
||||||
|
RoutingBasicItem =
|
||||||
|
new RoutingBasicItem
|
||||||
|
{
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
RoutingIndexId = string.Empty,
|
||||||
|
},
|
||||||
|
GuiItem = new GUIItem { EnableStatistics = false, DisplayRealTimeSpeed = false, EnableLog = false },
|
||||||
|
MsgUIItem = new MsgUIItem(),
|
||||||
|
UiItem =
|
||||||
|
new UIItem
|
||||||
|
{
|
||||||
|
CurrentLanguage = "en",
|
||||||
|
CurrentFontFamily = "sans",
|
||||||
|
MainColumnItem = [],
|
||||||
|
WindowSizeItem = []
|
||||||
|
},
|
||||||
|
ConstItem = new ConstItem(),
|
||||||
|
SpeedTestItem = new SpeedTestItem
|
||||||
|
{
|
||||||
|
SpeedPingTestUrl = Global.SpeedPingTestUrls.First(),
|
||||||
|
SpeedTestUrl = Global.SpeedTestUrls.First(),
|
||||||
|
SpeedTestTimeout = 10,
|
||||||
|
MixedConcurrencyCount = 1,
|
||||||
|
IPAPIUrl = string.Empty,
|
||||||
|
},
|
||||||
|
Mux4RayItem = new Mux4RayItem { Concurrency = 8, XudpConcurrency = 16, XudpProxyUDP443 = "reject" },
|
||||||
|
Mux4SboxItem = new Mux4SboxItem { Protocol = Global.SingboxMuxs.First(), MaxConnections = 8 },
|
||||||
|
HysteriaItem = new HysteriaItem { UpMbps = 100, DownMbps = 100 },
|
||||||
|
ClashUIItem = new ClashUIItem { ConnectionsColumnItem = [] },
|
||||||
|
SystemProxyItem =
|
||||||
|
new SystemProxyItem
|
||||||
|
{
|
||||||
|
SystemProxyExceptions = string.Empty,
|
||||||
|
SystemProxyAdvancedProtocol = string.Empty
|
||||||
|
},
|
||||||
|
WebDavItem = new WebDavItem(),
|
||||||
|
CheckUpdateItem = new CheckUpdateItem(),
|
||||||
|
Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Lengths = ["100-200"], Delays = ["10-20"] },
|
||||||
|
Inbound =
|
||||||
|
[
|
||||||
|
new InItem
|
||||||
|
{
|
||||||
|
Protocol = nameof(EInboundProtocol.socks),
|
||||||
|
LocalPort = 10808,
|
||||||
|
UdpEnabled = true,
|
||||||
|
SniffingEnabled = true,
|
||||||
|
RouteOnly = false,
|
||||||
|
DestOverride = ["http", "tls"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
GlobalHotkeys = [],
|
||||||
|
CoreTypeItem =
|
||||||
|
[
|
||||||
|
new CoreTypeItem { ConfigType = EConfigType.VMess, CoreType = vmessCoreType }
|
||||||
|
],
|
||||||
|
SimpleDNSItem = new SimpleDNSItem
|
||||||
|
{
|
||||||
|
BootstrapDNS = Global.DomainPureIPDNSAddress.FirstOrDefault(),
|
||||||
|
ServeStale = false,
|
||||||
|
ParallelQuery = false,
|
||||||
|
Strategy4Freedom = Global.AsIs,
|
||||||
|
Strategy4Proxy = Global.AsIs,
|
||||||
|
Strategy4ProxyDial = Global.AsIs,
|
||||||
|
},
|
||||||
|
IndexId = string.Empty,
|
||||||
|
SubIndexId = string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreateVmessNode(ECoreType coreType, string indexId = "node-1", string remarks = "demo")
|
||||||
|
{
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.VMess,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
Subid = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
node.SetProtocolExtra(node.GetProtocolExtra() with { AlterId = "0", VmessSecurity = Global.DefaultSecurity, });
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreateSocksNode(ECoreType coreType, string indexId = "node-socks-1",
|
||||||
|
string remarks = "demo-socks")
|
||||||
|
{
|
||||||
|
return new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.SOCKS,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
Address = "127.0.0.1",
|
||||||
|
Port = 1080,
|
||||||
|
Password = "pass",
|
||||||
|
Username = "user",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
Subid = string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreateHttpNode(ECoreType coreType, string indexId = "node-http-1",
|
||||||
|
string remarks = "demo-http")
|
||||||
|
{
|
||||||
|
return new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.HTTP,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
Address = "proxy.example.com",
|
||||||
|
Port = 8080,
|
||||||
|
Password = "pass",
|
||||||
|
Username = "user",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
Subid = string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreateCustomOutboundNode(ECoreType coreType, string indexId = "node-custom-1",
|
||||||
|
string remarks = "demo-custom-outbound", string address = "custom_outbound.json")
|
||||||
|
{
|
||||||
|
return new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.Outbound,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
Address = address,
|
||||||
|
Port = 0,
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
Subid = string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
|
||||||
|
IEnumerable<string> childIndexIds)
|
||||||
|
{
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.PolicyGroup,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
};
|
||||||
|
node.SetProtocolExtra(node.GetProtocolExtra() with
|
||||||
|
{
|
||||||
|
GroupType = nameof(EConfigType.PolicyGroup),
|
||||||
|
ChildItems = string.Join(",", childIndexIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProfileItem CreateProxyChainNode(ECoreType coreType, string indexId, string remarks,
|
||||||
|
IEnumerable<string> childIndexIds)
|
||||||
|
{
|
||||||
|
var node = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = indexId,
|
||||||
|
ConfigType = EConfigType.ProxyChain,
|
||||||
|
CoreType = coreType,
|
||||||
|
Remarks = remarks,
|
||||||
|
};
|
||||||
|
node.SetProtocolExtra(node.GetProtocolExtra() with
|
||||||
|
{
|
||||||
|
GroupType = nameof(EConfigType.ProxyChain),
|
||||||
|
ChildItems = string.Join(",", childIndexIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType,
|
||||||
|
bool hasGlobalIPv6Address = true)
|
||||||
|
{
|
||||||
|
return new CoreConfigContext
|
||||||
|
{
|
||||||
|
Node = node,
|
||||||
|
RunCoreType = runCoreType,
|
||||||
|
AppConfig = config,
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r1",
|
||||||
|
Remarks = "default",
|
||||||
|
RuleSet = "[]",
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
},
|
||||||
|
RawDnsItem = null,
|
||||||
|
SimpleDnsItem = config.SimpleDNSItem,
|
||||||
|
AllProxiesMap = new Dictionary<string, ProfileItem> { [node.IndexId] = node },
|
||||||
|
FullConfigTemplate = null,
|
||||||
|
IsTunEnabled = config.TunModeItem.EnableTun,
|
||||||
|
ProtectDomainList = [],
|
||||||
|
HasGlobalIPv6Address = hasGlobalIPv6Address,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config CreateConfigWithDirectExpectedIPs(ECoreType coreType,
|
||||||
|
string directExpectedIPs = "192.168.0.0/16,geoip:cn")
|
||||||
|
{
|
||||||
|
var config = CreateConfig(coreType);
|
||||||
|
config.SimpleDNSItem.DirectExpectedIPs = directExpectedIPs;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config CreateConfigWithBootstrapDNS(ECoreType coreType, string bootstrapDns = "8.8.8.8")
|
||||||
|
{
|
||||||
|
var config = CreateConfig(coreType);
|
||||||
|
config.SimpleDNSItem.BootstrapDNS = bootstrapDns;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config CreateConfigWithTunRouteExcludeAddress(ECoreType coreType)
|
||||||
|
{
|
||||||
|
var config = CreateConfig(coreType);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.RouteExcludeAddress = ["10.0.0.1/32", "192.168.1.0/24", "fc00::/7"];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config CreateConfigWithTun(ECoreType coreType, bool enableIPv6Address)
|
||||||
|
{
|
||||||
|
var config = CreateConfig(coreType);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.EnableIPv6Address = enableIPv6Address;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
namespace ServiceLib.Tests.CoreConfig.Singbox;
|
||||||
|
|
||||||
|
public class CoreConfigSingboxServiceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
|
var singboxConfig = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
|
||||||
|
await singboxConfig.Should().NotBeNull();
|
||||||
|
await singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
|
||||||
|
await singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunWithLoopbackPreSocks_ShouldKeepMixedInbound()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
|
||||||
|
node.Address = Global.Loopback;
|
||||||
|
node.Port = 1080;
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.inbounds.Should().Contain(i =>
|
||||||
|
i.type == nameof(EInboundProtocol.mixed)
|
||||||
|
&& i.listen == Global.Loopback
|
||||||
|
&& i.listen_port == AppManager.Instance.GetLocalPort(EInboundProtocol.socks));
|
||||||
|
await cfg.inbounds.Should().Contain(i => i.type == "tun");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules()
|
||||||
|
{
|
||||||
|
// The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast).
|
||||||
|
// They are deserialized into List<Rule4Sbox>, so a schema mismatch in the
|
||||||
|
// embedded template makes JsonUtils.Deserialize return null and silently
|
||||||
|
// drops every one of them.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.route.rules.Should().Contain(
|
||||||
|
r => r.action == "reject"
|
||||||
|
&& r.network != null && r.network.Contains("udp")
|
||||||
|
&& r.port != null && r.port.Contains(5353),
|
||||||
|
"the embedded tun rules must reject mDNS/NetBIOS noise");
|
||||||
|
await cfg.route.rules.Should().Contain(
|
||||||
|
r => r.action == "reject"
|
||||||
|
&& r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"),
|
||||||
|
"the embedded tun rules must reject multicast traffic");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses()
|
||||||
|
{
|
||||||
|
// Regression test: traffic addressed to the TUN interface's own addresses must
|
||||||
|
// never reach an outbound. auto_route hijacks the default route, so `direct`
|
||||||
|
// writes such a packet straight back into the TUN, which routes it to the
|
||||||
|
// outbound again - an infinite loop that pins a CPU core. Observed in the wild
|
||||||
|
// with WebRTC ICE connectivity checks against the TUN's own fc00::/7 ULA
|
||||||
|
// address, sustaining ~8k packets/s out of the TUN interface.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.EnableIPv6Address = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var tun = cfg.inbounds.First(i => i.type == "tun");
|
||||||
|
//tun.address.Should().NotBeNullOrEmpty();
|
||||||
|
await tun.address.Should().NotBeNull();
|
||||||
|
await tun.address.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
foreach (var address in tun.address!)
|
||||||
|
{
|
||||||
|
var self = IPAddress.Parse(address.Split('/').First());
|
||||||
|
var hostBits = self.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
|
||||||
|
var expected = $"{self}/{hostBits}";
|
||||||
|
await cfg.route.rules.Should().Contain(
|
||||||
|
r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(expected),
|
||||||
|
$"traffic to the TUN's own address '{address}' must be rejected, not routed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The match has to stay on the addresses themselves. sing-tun derives the TUN's DNS
|
||||||
|
// entry from the address right after the interface's own, and every prefix offered
|
||||||
|
// here leaves room for it, so a prefix match would drop system name lookups too.
|
||||||
|
var dropRule = cfg.route.rules.First(r =>
|
||||||
|
r.action == "reject" && r.method == "drop" && r.ip_cidr?.Count > 0);
|
||||||
|
//dropRule.ip_cidr!.Should().OnlyContain(c =>
|
||||||
|
// c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal));
|
||||||
|
await dropRule.ip_cidr.Should().All(c => c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.CoreBasicItem.BindInterface = "eth0";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||||
|
|
||||||
|
await proxy.bind_interface.Should().BeEqualTo("eth0");
|
||||||
|
await proxy.detour.Should().BeNull().Or.BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
|
||||||
|
[n1.IndexId, n2.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.sing_box);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
|
||||||
|
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
|
||||||
|
[n1.IndexId, n2.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[chain.IndexId] = chain;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o =>
|
||||||
|
o.tag == Global.ProxyTag &&
|
||||||
|
(o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
|
||||||
|
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n3", "node-3");
|
||||||
|
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
|
||||||
|
[n1.IndexId, n2.IndexId]);
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
|
||||||
|
[chain.IndexId, n3.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.sing_box);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[n3.IndexId] = n3;
|
||||||
|
context.AllProxiesMap[chain.IndexId] = chain;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
|
||||||
|
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n3", "node-3");
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
|
||||||
|
[n1.IndexId, n2.IndexId]);
|
||||||
|
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
|
||||||
|
[group.IndexId, n3.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[n3.IndexId] = n3;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
context.AllProxiesMap[chain.IndexId] = chain;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
|
||||||
|
await proxyCloneCount.Should().BeEqualTo(2);
|
||||||
|
|
||||||
|
var allCloneDetoursPointToGroupBranches = cfg.outbounds
|
||||||
|
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
|
||||||
|
.All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal));
|
||||||
|
await allCloneDetoursPointToGroupBranches.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-split-1",
|
||||||
|
Remarks = "split-direct-block",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["full:direct.example.com"],
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = Global.BlockTag,
|
||||||
|
Domain = ["full:block.example.com"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
var hasDirectRule = cfg.route.rules.Any(r =>
|
||||||
|
r.domain != null
|
||||||
|
&& r.domain.Contains("direct.example.com")
|
||||||
|
&& r.outbound == Global.DirectTag);
|
||||||
|
await hasDirectRule.Should().BeTrue();
|
||||||
|
|
||||||
|
var hasBlockRule = cfg.route.rules.Any(r =>
|
||||||
|
r.domain != null
|
||||||
|
&& r.domain.Contains("block.example.com")
|
||||||
|
&& r.action == "reject");
|
||||||
|
await hasBlockRule.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var routeNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-route", "route-node");
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-split-2",
|
||||||
|
Remarks = "split-remark",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = routeNode.Remarks,
|
||||||
|
Domain = ["full:route.example.com"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
context.AllProxiesMap[$"remark:{routeNode.Remarks}"] = routeNode;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var hasRouteRule = cfg.route.rules.Any(r =>
|
||||||
|
r.domain != null
|
||||||
|
&& r.domain.Contains("route.example.com")
|
||||||
|
&& (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
|
await hasRouteRule.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
|
||||||
|
{
|
||||||
|
var bootstrapDns = "8.8.8.8";
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await config.SimpleDNSItem.BootstrapDNS.Should().BeEqualTo(bootstrapDns);
|
||||||
|
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var bootstrapServer = cfg.dns?.servers.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag);
|
||||||
|
await bootstrapServer.Should().NotBeNull();
|
||||||
|
await bootstrapServer!.server.Should().Contain(bootstrapDns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
|
||||||
|
config.SimpleDNSItem.RemoteDNS = "9.9.9.9";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-direct-final",
|
||||||
|
Remarks = "direct-final",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Ip = ["0.0.0.0/0"],
|
||||||
|
Port = "0-65535",
|
||||||
|
Network = "tcp,udp",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.dns.final.Should().BeEqualTo(Global.SingboxDirectDNSTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule()
|
||||||
|
{
|
||||||
|
var config =
|
||||||
|
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-unmatched",
|
||||||
|
Remarks = "dns-direct-unmatched",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["geosite:us"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
var hasExpectedRule = cfg.dns.rules?.Any(r =>
|
||||||
|
r.server == Global.SingboxDirectDNSTag
|
||||||
|
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
|
||||||
|
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
|
||||||
|
await hasExpectedRule.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("geosite:cn", "geosite-cn")]
|
||||||
|
[Arguments("geosite:geolocation-cn", "geosite-geolocation-cn")]
|
||||||
|
[Arguments("geosite:tld-cn", "geosite-tld-cn")]
|
||||||
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag,
|
||||||
|
string expectedRuleSetTag)
|
||||||
|
{
|
||||||
|
var config =
|
||||||
|
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-variant",
|
||||||
|
Remarks = "dns-direct-variant",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.ProxyTag, Domain = ["geosite:google"],
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
var hasExpectedRule = (cfg.dns.rules?.Any(r =>
|
||||||
|
r.ip_cidr?.Contains("192.168.0.0/16") == true
|
||||||
|
&& r.action == "respond"
|
||||||
|
&& r.rule_set?.Contains("geoip-cn") == true) ?? false)
|
||||||
|
&& (cfg.dns.rules?.Any(r =>
|
||||||
|
r.server == Global.SingboxDirectDNSTag
|
||||||
|
&& r.rule_set?.Contains(expectedRuleSetTag) == true
|
||||||
|
&& r.action == "evaluate") ?? false);
|
||||||
|
await hasExpectedRule.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
|
||||||
|
config.SimpleDNSItem.DirectDNS = "https://resolver.example/dns-query";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
var hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag);
|
||||||
|
await hostsServer.Should().NotBeNull();
|
||||||
|
await hostsServer!.predefined.Should().ContainKey("resolver.example");
|
||||||
|
await hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1");
|
||||||
|
|
||||||
|
var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag);
|
||||||
|
await directServer.Should().NotBeNull();
|
||||||
|
await directServer!.domain_resolver.Should().BeEqualTo(Global.SingboxHostsDNSTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var rawDns = new Dns4Sbox
|
||||||
|
{
|
||||||
|
servers =
|
||||||
|
[
|
||||||
|
new Server4Sbox { tag = "remote", type = "udp", server = "8.8.8.8", detour = Global.ProxyTag, }
|
||||||
|
],
|
||||||
|
rules = [],
|
||||||
|
};
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RawDnsItem = new DNSItem
|
||||||
|
{
|
||||||
|
Id = "dns-raw-1",
|
||||||
|
Remarks = "raw",
|
||||||
|
Enabled = true,
|
||||||
|
CoreType = ECoreType.sing_box,
|
||||||
|
NormalDNS = JsonUtils.Serialize(rawDns),
|
||||||
|
DomainDNSAddress = "1.1.1.1",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.type == "udp" && s.server == "8.8.8.8");
|
||||||
|
await cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag);
|
||||||
|
await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Global));
|
||||||
|
await cfg.dns.rules.Should().Contain(r => r.clash_mode == nameof(ERuleMode.Direct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(true)]
|
||||||
|
[Arguments(false)]
|
||||||
|
public async Task GenerateClientConfigContent_MultiDnsServers_ShouldIncludeAllServers(bool parallelQuery)
|
||||||
|
{
|
||||||
|
var config =
|
||||||
|
CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.SimpleDNSItem.ParallelQuery = parallelQuery;
|
||||||
|
config.SimpleDNSItem.DirectDNS = "1.1.1.1,2.2.2.2";
|
||||||
|
config.SimpleDNSItem.RemoteDNS = "3.3.3.3,4.4.4.4";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-variant",
|
||||||
|
Remarks = "dns-direct-variant",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.ProxyTag,
|
||||||
|
Domain = ["geosite:google"],
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["geosite:cn"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.dns.servers.Where(s => s.tag.StartsWith(Global.SingboxDirectDNSTagPrefix)).Should().HaveCount(2);
|
||||||
|
await cfg.dns.servers.Where(s => s.tag.StartsWith(Global.SingboxRemoteDNSTagPrefix)).Should().HaveCount(2);
|
||||||
|
if (parallelQuery)
|
||||||
|
{
|
||||||
|
await cfg.dns.rules.Should().Contain(r => r.race == true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_Hysteria2Realm_ShouldEmitHttpsServerUrl()
|
||||||
|
{
|
||||||
|
var shareLink =
|
||||||
|
"hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
|
||||||
|
var node = Hysteria2Fmt.ResolveRealm(shareLink, out _);
|
||||||
|
await node.Should().NotBeNull();
|
||||||
|
node!.CoreType = ECoreType.sing_box;
|
||||||
|
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.CoreTypeItem =
|
||||||
|
[
|
||||||
|
new CoreTypeItem { ConfigType = EConfigType.Hysteria2, CoreType = ECoreType.sing_box }
|
||||||
|
];
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
|
||||||
|
|
||||||
|
await proxy.type.Should().BeEqualTo("hysteria2");
|
||||||
|
await proxy.realm.Should().NotBeNull();
|
||||||
|
await proxy.realm!.server_url.Should().StartWith("https://");
|
||||||
|
await proxy.realm.server_url.Should().Contain("realm.hy2.io");
|
||||||
|
await proxy.realm.token.Should().BeEqualTo("public");
|
||||||
|
await proxy.realm.realm_id.Should().BeEqualTo("my-realm-id");
|
||||||
|
await proxy.realm.stun_servers.Should().Contain("turn.cloudflare.com:3478");
|
||||||
|
await proxy.server.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunSystemStackWithIpv6_ShouldUsePrefixWithPeerAddress()
|
||||||
|
{
|
||||||
|
// Regression test for #9820: sing-box fails with "need one more IPv6 address in
|
||||||
|
// first prefix for system stack" when the TUN inbound uses a /128 IPv6 prefix.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
config.TunModeItem.EnableTun = true;
|
||||||
|
config.TunModeItem.Stack = "system";
|
||||||
|
config.TunModeItem.EnableIPv6Address = true;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
|
||||||
|
{
|
||||||
|
IsTunEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
|
||||||
|
var tun = cfg.inbounds.First(i => i.type == "tun");
|
||||||
|
|
||||||
|
await tun.address.Should().NotBeNull();
|
||||||
|
await tun.address.Should().NotBeEmpty();
|
||||||
|
foreach (var address in tun.address!)
|
||||||
|
{
|
||||||
|
var prefixLength = int.Parse(address[(address.LastIndexOf('/') + 1)..]);
|
||||||
|
var isIpv6 = address.Contains(':');
|
||||||
|
await prefixLength.Should().BeLessThanOrEqualTo(isIpv6 ? 126 : 30,
|
||||||
|
$"'{address}' must leave room for the peer address the system stack derives");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.sing_box, "n-custom", "custom-singbox");
|
||||||
|
var customJsonContent = """
|
||||||
|
{
|
||||||
|
"type": "shadowsocks",
|
||||||
|
"server": "1.2.3.4",
|
||||||
|
"server_port": 8388,
|
||||||
|
"method": "aes-128-gcm",
|
||||||
|
"password": "custom_password"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.sing_box);
|
||||||
|
context.CustomOutboundContent[customNode.IndexId] = customJsonContent;
|
||||||
|
|
||||||
|
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
|
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
|
||||||
|
await cfg.Should().NotBeNull();
|
||||||
|
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
|
||||||
|
await proxyOutbound.Should().NotBeNull();
|
||||||
|
await proxyOutbound!.type.Should().BeEqualTo("shadowsocks");
|
||||||
|
await proxyOutbound.server.Should().BeEqualTo("1.2.3.4");
|
||||||
|
await proxyOutbound.server_port.Should().BeEqualTo(8388);
|
||||||
|
await proxyOutbound.method.Should().BeEqualTo("aes-128-gcm");
|
||||||
|
await proxyOutbound.password.Should().BeEqualTo("custom_password");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
namespace ServiceLib.Tests.CoreConfig.V2ray;
|
||||||
|
|
||||||
|
public class CoreConfigV2rayServiceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
|
var v2rayConfig = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
|
||||||
|
await v2rayConfig.Should().NotBeNull();
|
||||||
|
await v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess");
|
||||||
|
await v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
var node = CoreConfigTestFactory.CreateHttpNode(ECoreType.Xray);
|
||||||
|
node.SetProtocolExtra(node.GetProtocolExtra() with
|
||||||
|
{
|
||||||
|
HttpHeaders = "{\"User-Agent\":\"v2rayN\",\"Set-Cookie\":[\"a=1\",\"b=2\"]}",
|
||||||
|
});
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var outbound = cfg.outbounds.First(o => o.tag == Global.ProxyTag && o.protocol == "http");
|
||||||
|
|
||||||
|
await outbound.settings.address!.ToString().Should().BeEqualTo("proxy.example.com");
|
||||||
|
await outbound.settings.port.Should().BeEqualTo(8080);
|
||||||
|
await outbound.settings.user.Should().BeEqualTo("user");
|
||||||
|
await outbound.settings.pass.Should().BeEqualTo("pass");
|
||||||
|
await outbound.settings.level.Should().BeEqualTo(1);
|
||||||
|
await outbound.settings.headers.Should().NotBeNull();
|
||||||
|
var headers = JsonUtils.ParseJson(outbound.settings.headers!.ToString());
|
||||||
|
await headers["User-Agent"]!.GetValue<string>().Should().BeEqualTo("v2rayN");
|
||||||
|
await headers["Set-Cookie"]!.AsArray()
|
||||||
|
.Select(item => item!.GetValue<string>())
|
||||||
|
.Should().BeEquivalentTo(["a=1", "b=2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
|
||||||
|
[n1.IndexId, n2.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
|
await cfg.routing.balancers.Should().NotBeNull();
|
||||||
|
await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
|
||||||
|
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain", [n1.IndexId, n2.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[chain.IndexId] = chain;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
|
var hasDialerChain = cfg.outbounds.Any(o =>
|
||||||
|
o.tag == Global.ProxyTag
|
||||||
|
&& o.streamSettings is not null
|
||||||
|
&& o.streamSettings.sockopt is not null
|
||||||
|
&& (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
await hasDialerChain.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
|
||||||
|
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n3", "node-3");
|
||||||
|
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain", [n1.IndexId, n2.IndexId]);
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
|
||||||
|
[chain.IndexId, n3.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[n3.IndexId] = n3;
|
||||||
|
context.AllProxiesMap[chain.IndexId] = chain;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
|
||||||
|
await cfg.routing.balancers.Should().NotBeNull();
|
||||||
|
await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
|
||||||
|
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
|
||||||
|
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n3", "node-3");
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
|
||||||
|
[n1.IndexId, n2.IndexId]);
|
||||||
|
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain",
|
||||||
|
[group.IndexId, n3.IndexId]);
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.Xray);
|
||||||
|
context.AllProxiesMap[n1.IndexId] = n1;
|
||||||
|
context.AllProxiesMap[n2.IndexId] = n2;
|
||||||
|
context.AllProxiesMap[n3.IndexId] = n3;
|
||||||
|
context.AllProxiesMap[group.IndexId] = group;
|
||||||
|
context.AllProxiesMap[chain.IndexId] = chain;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
|
||||||
|
await proxyCloneCount.Should().BeEqualTo(2);
|
||||||
|
|
||||||
|
var allCloneDialersPointToGroupBranches = cfg.outbounds
|
||||||
|
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
|
||||||
|
.All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
await allCloneDialersPointToGroupBranches.Should().BeTrue();
|
||||||
|
|
||||||
|
await cfg.routing.balancers.Should().NotBeNull();
|
||||||
|
await cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-split-1",
|
||||||
|
Remarks = "split-direct-block",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["full:direct.example.com"],
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = Global.BlockTag,
|
||||||
|
Domain = ["full:block.example.com"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
|
||||||
|
var hasDirectRule = cfg.routing.rules.Any(r =>
|
||||||
|
r.domain != null
|
||||||
|
&& r.domain.Contains("full:direct.example.com")
|
||||||
|
&& r.outboundTag == Global.DirectTag);
|
||||||
|
await hasDirectRule.Should().BeTrue();
|
||||||
|
|
||||||
|
var hasBlockRule = cfg.routing.rules.Any(r =>
|
||||||
|
r.domain != null
|
||||||
|
&& r.domain.Contains("full:block.example.com")
|
||||||
|
&& r.outboundTag == Global.BlockTag);
|
||||||
|
await hasBlockRule.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var routeNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n-route", "route-node");
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-split-2",
|
||||||
|
Remarks = "split-remark",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = routeNode.Remarks,
|
||||||
|
Domain = ["full:route.example.com"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
context.AllProxiesMap[$"remark:{routeNode.Remarks}"] = routeNode;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
|
||||||
|
|
||||||
|
await cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
|
var hasRouteRule = cfg.routing.rules.Any(r =>
|
||||||
|
r.domain != null
|
||||||
|
&& r.domain.Contains("full:route.example.com")
|
||||||
|
&& (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
|
||||||
|
await hasRouteRule.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-expected",
|
||||||
|
Remarks = "dns-direct-expected",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["geosite:cn"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
|
var dnsServers = dns.servers
|
||||||
|
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Cast<DnsServer4Ray>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hasExpectedServer = dnsServers.Any(s =>
|
||||||
|
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
|
||||||
|
&& s.domains?.Contains("geosite:cn") == true
|
||||||
|
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
|
||||||
|
&& s.expectedIPs?.Contains("geoip:cn") == true);
|
||||||
|
await hasExpectedServer.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains()
|
||||||
|
{
|
||||||
|
var bootstrapDns = "8.8.8.8";
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns);
|
||||||
|
config.SimpleDNSItem.DirectDNS = "https://dns-direct.example/dns-query";
|
||||||
|
config.SimpleDNSItem.RemoteDNS = "https://dns-remote.example/dns-query";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
|
var dnsServers = dns.servers
|
||||||
|
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Cast<DnsServer4Ray>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hasBootstrapServer = dnsServers.Any(s =>
|
||||||
|
s.address == bootstrapDns
|
||||||
|
&& s.domains?.Contains("full:dns-direct.example") == true
|
||||||
|
&& s.domains?.Contains("full:dns-remote.example") == true);
|
||||||
|
await hasBootstrapServer.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
|
||||||
|
config.SimpleDNSItem.RemoteDNS = "9.9.9.9";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-direct-final",
|
||||||
|
Remarks = "direct-final",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.Routing,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Ip = ["0.0.0.0/0"],
|
||||||
|
Port = "0-65535",
|
||||||
|
Network = "tcp,udp",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
var dnsServers = dns.servers
|
||||||
|
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Cast<DnsServer4Ray>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hasDirectFallback = dnsServers.Any(s =>
|
||||||
|
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
|
||||||
|
&& s.address == "1.1.1.1");
|
||||||
|
await hasDirectFallback.Should().BeTrue();
|
||||||
|
|
||||||
|
var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9");
|
||||||
|
await hasRemoteFallback.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-unmatched",
|
||||||
|
Remarks = "dns-direct-unmatched",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RuleType = ERuleType.DNS,
|
||||||
|
OutboundTag = Global.DirectTag,
|
||||||
|
Domain = ["geosite:us"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
var dnsServers = dns.servers
|
||||||
|
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Cast<DnsServer4Ray>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hasExpectedIPs = dnsServers.Any(s =>
|
||||||
|
s.expectedIPs?.Contains("192.168.0.0/16") == true
|
||||||
|
|| s.expectedIPs?.Contains("geoip:cn") == true);
|
||||||
|
await hasExpectedIPs.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("geosite:cn")]
|
||||||
|
[Arguments("geosite:geolocation-cn")]
|
||||||
|
[Arguments("geosite:tld-cn")]
|
||||||
|
public async Task GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RoutingItem = new RoutingItem
|
||||||
|
{
|
||||||
|
Id = "r-dns-direct-variant",
|
||||||
|
Remarks = "dns-direct-variant",
|
||||||
|
RuleSet = JsonUtils.Serialize(new List<RulesItem>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DomainStrategy = Global.AsIs,
|
||||||
|
DomainStrategy4Singbox = string.Empty,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
var dnsServers = dns.servers
|
||||||
|
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
|
||||||
|
.Where(s => s is not null)
|
||||||
|
.Cast<DnsServer4Ray>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hasExpectedServer = dnsServers.Any(s =>
|
||||||
|
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
|
||||||
|
&& s.domains?.Contains(domainTag) == true
|
||||||
|
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
|
||||||
|
&& s.expectedIPs?.Contains("geoip:cn") == true);
|
||||||
|
await hasExpectedServer.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
|
await dns.hosts.Should().NotBeNull();
|
||||||
|
await dns.hosts!.Should().ContainKey("resolver.example");
|
||||||
|
await JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
|
||||||
|
{
|
||||||
|
RawDnsItem = new DNSItem
|
||||||
|
{
|
||||||
|
Id = "dns-raw-1",
|
||||||
|
Remarks = "raw",
|
||||||
|
Enabled = true,
|
||||||
|
CoreType = ECoreType.Xray,
|
||||||
|
NormalDNS = "{\"servers\":[\"8.8.8.8\"],\"hosts\":{\"raw.example\":\"1.1.1.1\"}}",
|
||||||
|
DomainStrategy4Freedom = "UseIPv4",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
|
||||||
|
|
||||||
|
await JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8");
|
||||||
|
await dns.hosts.Should().NotBeNull();
|
||||||
|
await dns.hosts!.Should().ContainKey("raw.example");
|
||||||
|
await JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1");
|
||||||
|
|
||||||
|
var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom");
|
||||||
|
await directOutbound.Should().NotBeNull();
|
||||||
|
await directOutbound!.streamSettings.sockopt!.domainStrategy.Should().BeEqualTo("UseIPv4");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(false)]
|
||||||
|
[Arguments(true)]
|
||||||
|
public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6IntoTunnel(bool enableIPv6Address)
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
|
await tunInbound.Should().NotBeNull();
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("0.0.0.0/0");
|
||||||
|
await tunInbound.settings.autoSystemRoutingTable.Should().Contain("::/0");
|
||||||
|
|
||||||
|
// EnableIPv6Address governs the interface address only, never the routing table.
|
||||||
|
await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(true)]
|
||||||
|
[Arguments(false)]
|
||||||
|
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
|
||||||
|
{
|
||||||
|
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
|
||||||
|
// while ::/0 would pull IPv6 attempts into a tunnel they cannot leave.
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
|
await tunInbound.Should().NotBeNull();
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("0.0.0.0/0");
|
||||||
|
var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList();
|
||||||
|
await ipv6Routes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
|
||||||
|
config.TunModeItem.EnableIPv6Address = false;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
|
await tunInbound.Should().NotBeNull();
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().NotBeEmpty();
|
||||||
|
var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList();
|
||||||
|
await ipv6Routes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
|
||||||
|
config.TunModeItem.EnableIPv6Address = false;
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
|
await tunInbound.Should().NotBeNull();
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain(x => x.Contains(':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_TunRouteExcludeAddress()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue();
|
||||||
|
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
|
||||||
|
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
|
||||||
|
|
||||||
|
await tunInbound.Should().NotBeNull();
|
||||||
|
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().NotContain("0.0.0.0/0");
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.0/32");
|
||||||
|
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("10.0.0.2/31");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
|
||||||
|
{
|
||||||
|
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
|
||||||
|
CoreConfigTestFactory.BindAppManagerConfig(config);
|
||||||
|
|
||||||
|
var customNode = CoreConfigTestFactory.CreateCustomOutboundNode(ECoreType.Xray, "n-custom", "custom-xray");
|
||||||
|
var customJsonContent = """
|
||||||
|
{
|
||||||
|
"protocol": "shadowsocks",
|
||||||
|
"settings": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"address": "1.2.3.4",
|
||||||
|
"port": 8388,
|
||||||
|
"method": "aes-128-gcm",
|
||||||
|
"password": "custom_password"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var context = CoreConfigTestFactory.CreateContext(config, customNode, ECoreType.Xray);
|
||||||
|
context.CustomOutboundContent[customNode.IndexId] = customJsonContent;
|
||||||
|
|
||||||
|
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
|
||||||
|
|
||||||
|
await result.Success.Should().BeTrue().Because($"ret msg: {result.Msg}");
|
||||||
|
await result.Data.Should().NotBeNull();
|
||||||
|
|
||||||
|
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
|
||||||
|
await cfg.Should().NotBeNull();
|
||||||
|
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
|
||||||
|
await proxyOutbound.Should().NotBeNull();
|
||||||
|
await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class FmtHandlerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One profile factory per protocol that <see cref="FmtHandler.GetShareUri" /> can export.
|
||||||
|
/// The suite below asserts that this map and <see cref="Global.ProtocolShares" /> agree, so a
|
||||||
|
/// newly exportable protocol cannot be added without a round-trip case.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<EConfigType, Func<ProfileItem>> ShareProfileFactories = new()
|
||||||
|
{
|
||||||
|
[EConfigType.VMess] = CreateVmessProfile,
|
||||||
|
[EConfigType.Shadowsocks] = CreateShadowsocksProfile,
|
||||||
|
[EConfigType.SOCKS] = CreateSocksProfile,
|
||||||
|
[EConfigType.VLESS] = CreateVlessProfile,
|
||||||
|
[EConfigType.Trojan] = CreateTrojanProfile,
|
||||||
|
[EConfigType.Hysteria2] = CreateHysteria2Profile,
|
||||||
|
[EConfigType.TUIC] = CreateTuicProfile,
|
||||||
|
[EConfigType.WireGuard] = CreateWireguardProfile,
|
||||||
|
[EConfigType.Anytls] = CreateAnytlsProfile,
|
||||||
|
[EConfigType.Naive] = () => CreateNaiveProfile(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShareUriSuite_ShouldCoverAndRoundTripEveryExportableProtocol()
|
||||||
|
{
|
||||||
|
var uncovered = string.Join(", ", Global.ProtocolShares.Keys.Except(ShareProfileFactories.Keys));
|
||||||
|
var unexpected = string.Join(", ", ShareProfileFactories.Keys.Except(Global.ProtocolShares.Keys));
|
||||||
|
|
||||||
|
await uncovered.Should().BeEqualTo(string.Empty);
|
||||||
|
await unexpected.Should().BeEqualTo(string.Empty);
|
||||||
|
|
||||||
|
foreach (var (configType, factory) in ShareProfileFactories)
|
||||||
|
{
|
||||||
|
var source = factory();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(configType);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateVmessProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.VMess);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().AlterId.Should().BeEqualTo(source.GetProtocolExtra().AlterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateVlessProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.VLESS);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().VlessEncryption.Should().BeEqualTo(Global.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateShadowsocksProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.Shadowsocks);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().SsMethod.Should().BeEqualTo(source.GetProtocolExtra().SsMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateSocksProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(EConfigType.SOCKS);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateTrojanProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.GetProtocolExtra().Flow.Should().BeEqualTo(source.GetProtocolExtra().Flow);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
|
||||||
|
// Trojan is the one exporter that writes both spellings of the flag.
|
||||||
|
await AssertExportContains(source, "allowInsecure=1", "insecure=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Tuic_ShouldRoundTripUserInfoAndCongestionControl()
|
||||||
|
{
|
||||||
|
var source = CreateTuicProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
|
||||||
|
await resolved.GetProtocolExtra().CongestionControl.Should()
|
||||||
|
.BeEqualTo(source.GetProtocolExtra().CongestionControl);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
|
||||||
|
await AssertExportContains(source, "allow_insecure=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Anytls_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var source = CreateAnytlsProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
|
||||||
|
await AssertExportContains(source, "insecure=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Hysteria2_ShouldRoundTripObfsAndNormalizePortRange()
|
||||||
|
{
|
||||||
|
var source = CreateHysteria2Profile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
var sourceExtra = source.GetProtocolExtra();
|
||||||
|
var resolvedExtra = resolved.GetProtocolExtra();
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.Sni.Should().BeEqualTo(source.Sni);
|
||||||
|
await resolved.Alpn.Should().BeEqualTo(source.Alpn);
|
||||||
|
await resolved.EchConfigList.Should().BeEqualTo(source.EchConfigList);
|
||||||
|
await resolved.GetAllowInsecure().Should().BeTrue();
|
||||||
|
await resolvedExtra.SalamanderPass.Should().BeEqualTo(sourceExtra.SalamanderPass);
|
||||||
|
|
||||||
|
// Hysteria2Fmt stores a port range internally as "5000:6000" and emits the URI form.
|
||||||
|
await resolvedExtra.Ports.Should().BeEqualTo("5000-6000");
|
||||||
|
|
||||||
|
await AssertExportContains(source, "insecure=1", "obfs=salamander", "mport=5000-6000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Wireguard_ShouldRoundTripKeysAndInterface()
|
||||||
|
{
|
||||||
|
var source = CreateWireguardProfile();
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
var extra = resolved.GetProtocolExtra();
|
||||||
|
var sourceExtra = source.GetProtocolExtra();
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await extra.WgPublicKey.Should().BeEqualTo(sourceExtra.WgPublicKey);
|
||||||
|
await extra.WgPresharedKey.Should().BeEqualTo(sourceExtra.WgPresharedKey);
|
||||||
|
await extra.WgReserved.Should().BeEqualTo(sourceExtra.WgReserved);
|
||||||
|
await extra.WgInterfaceAddress.Should().BeEqualTo(sourceExtra.WgInterfaceAddress);
|
||||||
|
await extra.WgMtu.Should().BeEqualTo(sourceExtra.WgMtu);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUri_Wireguard_ShouldEncodeKeysAndBracketIpv6()
|
||||||
|
{
|
||||||
|
var source = CreateWireguardProfile();
|
||||||
|
|
||||||
|
// Base64 keys carry '/', '+' and '=', and the address is an IPv6 literal: both have to
|
||||||
|
// survive the wire form, which a round trip through the same encoder would not prove.
|
||||||
|
await AssertExportContains(
|
||||||
|
source,
|
||||||
|
Uri.EscapeDataString(source.Password),
|
||||||
|
Uri.EscapeDataString(source.GetProtocolExtra().WgPublicKey ?? string.Empty),
|
||||||
|
"@[2001:db8::40]:51820");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Naive_ShouldRoundTripCredentialsOverHttps()
|
||||||
|
{
|
||||||
|
var source = CreateNaiveProfile(false);
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source, Global.NaiveHttpsProtocolShare);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().InsecureConcurrency.Should()
|
||||||
|
.BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
|
||||||
|
|
||||||
|
// NaiveFmt only ever sets this flag on the quic branch, so the https branch leaves it
|
||||||
|
// unset rather than false - assert "is not quic" instead of an explicit false.
|
||||||
|
await (resolved.GetProtocolExtra().NaiveQuic == true).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_NaiveQuic_ShouldRoundTripQuicScheme()
|
||||||
|
{
|
||||||
|
var source = CreateNaiveProfile(true);
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source, Global.NaiveQuicProtocolShare);
|
||||||
|
|
||||||
|
await AssertCommonShareFields(source, resolved);
|
||||||
|
await AssertRawTransportFields(source, resolved);
|
||||||
|
await resolved.Username.Should().BeEqualTo(source.Username);
|
||||||
|
await resolved.Password.Should().BeEqualTo(source.Password);
|
||||||
|
await resolved.GetProtocolExtra().InsecureConcurrency.Should()
|
||||||
|
.BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
|
||||||
|
await resolved.GetProtocolExtra().NaiveQuic.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("p:a@ss#% +/=")]
|
||||||
|
[Arguments("пароль 東京")]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripEncodedCredentials(string password)
|
||||||
|
{
|
||||||
|
var source = CreateTrojanProfile();
|
||||||
|
source.Password = password;
|
||||||
|
source.Remarks = "Trojan — тест 東京 #1";
|
||||||
|
|
||||||
|
var resolved = await ExportThenImport(source);
|
||||||
|
|
||||||
|
await resolved.Password.Should().BeEqualTo(password);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().BeNull();
|
||||||
|
await msg.Should().NotBeNull();
|
||||||
|
await msg.Should().NotBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUri_UnsupportedConfigType_ShouldReturnNull()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", };
|
||||||
|
|
||||||
|
var uri = FmtHandler.GetShareUri(item);
|
||||||
|
|
||||||
|
await uri.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AssertCommonShareFields(ProfileItem source, ProfileItem resolved)
|
||||||
|
{
|
||||||
|
await resolved.ConfigType.Should().BeEqualTo(source.ConfigType);
|
||||||
|
await resolved.Remarks.Should().BeEqualTo(source.Remarks);
|
||||||
|
await resolved.Address.Should().BeEqualTo(source.Address);
|
||||||
|
await resolved.Port.Should().BeEqualTo(source.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only for protocols whose exporter goes through the shared transport query
|
||||||
|
/// (<c>security</c>, <c>type</c>, <c>headerType</c>). TUIC, Hysteria2 and WireGuard do not.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task AssertRawTransportFields(ProfileItem source, ProfileItem resolved)
|
||||||
|
{
|
||||||
|
await resolved.Network.Should().BeEqualTo(source.Network);
|
||||||
|
await resolved.StreamSecurity.Should().BeEqualTo(source.StreamSecurity);
|
||||||
|
await resolved.GetTransportExtra().RawHeaderType.Should()
|
||||||
|
.BeEqualTo(source.GetTransportExtra().RawHeaderType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asserts on the wire form itself. A round trip cannot catch an exporter and an importer that
|
||||||
|
/// agree on the wrong spelling of a parameter, and the insecure flag is spelled differently by
|
||||||
|
/// every protocol.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task AssertExportContains(ProfileItem source, params string[] expectedFragments)
|
||||||
|
{
|
||||||
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
|
||||||
|
foreach (var fragment in expectedFragments)
|
||||||
|
{
|
||||||
|
await uri!.Contains(fragment, StringComparison.Ordinal).Should()
|
||||||
|
.BeTrue().Because($"uri: {uri}, expected fragment: {fragment}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExpectedShareScheme(ProfileItem item)
|
||||||
|
{
|
||||||
|
if (item.ConfigType != EConfigType.Naive)
|
||||||
|
{
|
||||||
|
return Global.ProtocolShares[item.ConfigType];
|
||||||
|
}
|
||||||
|
|
||||||
|
// NaiveFmt never emits the "naive://" prefix that Global.ProtocolShares records for the
|
||||||
|
// type; that entry is only read when importing.
|
||||||
|
return item.GetProtocolExtra().NaiveQuic == true
|
||||||
|
? Global.NaiveQuicProtocolShare
|
||||||
|
: Global.NaiveHttpsProtocolShare;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ProfileItem> ExportThenImport(ProfileItem source)
|
||||||
|
{
|
||||||
|
return await ExportThenImport(source, ExpectedShareScheme(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ProfileItem> ExportThenImport(ProfileItem source, string expectedPrefix)
|
||||||
|
{
|
||||||
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
await uri.Should().NotBeEmpty();
|
||||||
|
await uri!.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase).Should().BeTrue();
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(uri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
|
||||||
|
return resolved!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateVmessProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.VMess,
|
||||||
|
Remarks = "vmess demo",
|
||||||
|
Address = "example.com",
|
||||||
|
Port = 443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { AlterId = "0", VmessSecurity = Global.DefaultSecurity, });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateVlessProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.VLESS,
|
||||||
|
Remarks = "vless demo",
|
||||||
|
Address = "vless.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = Guid.NewGuid().ToString(),
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None, });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateShadowsocksProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Shadowsocks,
|
||||||
|
Remarks = "ss demo",
|
||||||
|
Address = "1.2.3.4",
|
||||||
|
Port = 8388,
|
||||||
|
Password = "pass123",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { SsMethod = "aes-128-gcm", });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateSocksProfile()
|
||||||
|
{
|
||||||
|
return new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.SOCKS,
|
||||||
|
Remarks = "socks demo",
|
||||||
|
Address = "127.0.0.1",
|
||||||
|
Port = 1080,
|
||||||
|
Username = "user",
|
||||||
|
Password = "pass",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateTrojanProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Trojan,
|
||||||
|
Remarks = "trojan demo",
|
||||||
|
Address = "trojan.example",
|
||||||
|
Port = 443,
|
||||||
|
Password = "trojan-pass",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.StreamSecurity,
|
||||||
|
Sni = "sni.trojan.example",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { Flow = Global.Flows[1], });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateTuicProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.TUIC,
|
||||||
|
Remarks = "tuic demo",
|
||||||
|
Address = "tuic.example",
|
||||||
|
Port = 8443,
|
||||||
|
// A colon separates the two halves of the TUIC user info, so it cannot appear in the
|
||||||
|
// uuid; a fixed value also keeps a failure reproducible.
|
||||||
|
Username = "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
Password = "tuic-pass",
|
||||||
|
Sni = "sni.tuic.example",
|
||||||
|
Alpn = "h3",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { CongestionControl = "bbr", });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateAnytlsProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Anytls,
|
||||||
|
Remarks = "anytls demo",
|
||||||
|
Address = "anytls.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = "anytls-pass",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.StreamSecurity,
|
||||||
|
Sni = "sni.anytls.example",
|
||||||
|
Alpn = "h2,http/1.1",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateHysteria2Profile()
|
||||||
|
{
|
||||||
|
// CertSha is deliberately left unset: the importer turns AllowInsecure on by itself when a
|
||||||
|
// pinSHA256 is present, which would mask an exporter that stopped emitting insecure=1.
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Hysteria2,
|
||||||
|
Remarks = "hysteria2 demo",
|
||||||
|
Address = "hy2.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = "demo-user:demo-pass",
|
||||||
|
Sni = "sni.hy2.example",
|
||||||
|
Alpn = "h3",
|
||||||
|
EchConfigList = "AAj+DQAEAAAAAA==",
|
||||||
|
AllowInsecure = Global.StringTrue,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem
|
||||||
|
{
|
||||||
|
SalamanderPass = "salamander-pass",
|
||||||
|
Ports = "5000:6000",
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateWireguardKey(byte value)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(Enumerable.Repeat(value, 32).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateWireguardProfile()
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.WireGuard,
|
||||||
|
Remarks = "WireGuard — тест 東京 #1",
|
||||||
|
Address = "2001:db8::40",
|
||||||
|
Port = 51820,
|
||||||
|
Password = CreateWireguardKey(0xFE),
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem
|
||||||
|
{
|
||||||
|
WgPublicKey = CreateWireguardKey(0xFD),
|
||||||
|
WgPresharedKey = CreateWireguardKey(0xFC),
|
||||||
|
WgReserved = "1,2,255",
|
||||||
|
WgInterfaceAddress = "10.0.0.2/32,fd00::2/128",
|
||||||
|
WgMtu = 1420,
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileItem CreateNaiveProfile(bool quic)
|
||||||
|
{
|
||||||
|
var item = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Naive,
|
||||||
|
Remarks = quic ? "naive quic demo" : "naive https demo",
|
||||||
|
Address = "naive.example",
|
||||||
|
Port = 443,
|
||||||
|
Username = "naive-user",
|
||||||
|
Password = "päss:word@/?#&=+ 東京",
|
||||||
|
Network = nameof(ETransport.raw),
|
||||||
|
StreamSecurity = Global.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.SetProtocolExtra(new ProtocolExtraItem { NaiveQuic = quic, InsecureConcurrency = 4, });
|
||||||
|
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class HyRealmTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task TryParse_ShouldParseValidRealm()
|
||||||
|
{
|
||||||
|
var str = "realm://public@realm.hy2.io/57f9be7c-2810-4f5b-8cb9-260bc84d6c90?stun=example.stun:3478&stun=example2.stun:3478";
|
||||||
|
var result = HyRealm.TryParse(str, out var realm);
|
||||||
|
await result.Should().BeTrue();
|
||||||
|
await realm.Should().NotBeNull();
|
||||||
|
|
||||||
|
await realm.IsHttp.Should().BeFalse();
|
||||||
|
await realm.Token.Should().BeEqualTo("public");
|
||||||
|
await realm.RendezvousHost.Should().BeEqualTo("realm.hy2.io");
|
||||||
|
await realm.RendezvousPort.Should().BeEqualTo(443);
|
||||||
|
await realm.RealmName.Should().BeEqualTo("57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
|
||||||
|
await realm.StunList.Should().HaveCount(2);
|
||||||
|
await realm.StunList.Should().Contain("example.stun:3478");
|
||||||
|
await realm.StunList.Should().Contain("example2.stun:3478");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ToUri_ShouldGenerateValidUri()
|
||||||
|
{
|
||||||
|
var realm = new HyRealm(
|
||||||
|
IsHttp: false,
|
||||||
|
Token: "public",
|
||||||
|
RendezvousHost: "realm.hy2.io",
|
||||||
|
RendezvousPort: 443,
|
||||||
|
RealmName: "57f9be7c-2810-4f5b-8cb9-260bc84d6c90",
|
||||||
|
StunList: ["example.stun:3478", "example2.stun:3478"]
|
||||||
|
);
|
||||||
|
var uri = realm.ToUri();
|
||||||
|
await uri.Should().Contain("realm://public@realm.hy2.io");
|
||||||
|
await uri.Should().Contain("/57f9be7c-2810-4f5b-8cb9-260bc84d6c90");
|
||||||
|
await uri.Should().Contain("stun=example.stun:3478");
|
||||||
|
await uri.Should().Contain("stun=example2.stun:3478");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetShareUriAndResolveConfig_Hy2Realm_ShouldRoundTripBasicFields()
|
||||||
|
{
|
||||||
|
var str = "hysteria2+realm://mytoken@rendezvous.example.com/my-cabin-1f3a8c2e9b?auth=your_password&insecure=1&pinSHA256=deadbeef#remark";
|
||||||
|
var resolved = Hysteria2Fmt.ResolveRealm(str, out var msg);
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved.Password.Should().BeEqualTo("your_password");
|
||||||
|
var result = HyRealm.TryParse(resolved.GetProtocolExtra().Hy2RealmUrl, out var realm);
|
||||||
|
await result.Should().BeTrue();
|
||||||
|
await realm.Should().NotBeNull();
|
||||||
|
await realm.Token.Should().BeEqualTo("mytoken");
|
||||||
|
|
||||||
|
// To uri
|
||||||
|
var uri = Hysteria2Fmt.ToUri(resolved);
|
||||||
|
await uri.Should().Contain("hysteria2+realm://mytoken@rendezvous.example.com");
|
||||||
|
await uri.Should().EndWith("#remark");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ToServerUrl_ShouldIncludeSchemeForSingbox()
|
||||||
|
{
|
||||||
|
var realm = new HyRealm(
|
||||||
|
IsHttp: false,
|
||||||
|
Token: "public",
|
||||||
|
RendezvousHost: "realm.hy2.io",
|
||||||
|
RendezvousPort: 443,
|
||||||
|
RealmName: "my-realm-id",
|
||||||
|
StunList: ["turn.cloudflare.com:3478"]
|
||||||
|
);
|
||||||
|
|
||||||
|
await realm.ToServerUrl().Should().BeEqualTo("https://realm.hy2.io:443");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveRealm_Issue9635_ShouldProduceHttpsServerUrl()
|
||||||
|
{
|
||||||
|
var str = "hysteria2+realm://public@realm.hy2.io/my-realm-id?auth=uuid&stun=turn.cloudflare.com%3A3478&sni=cloudflare.com&pinSHA256=xxx#Realm-Test";
|
||||||
|
var resolved = Hysteria2Fmt.ResolveRealm(str, out _);
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
|
||||||
|
await HyRealm.TryParse(resolved!.GetProtocolExtra().Hy2RealmUrl, out var realm).Should().BeTrue();
|
||||||
|
await realm!.ToServerUrl().Should().StartWith("https://");
|
||||||
|
await realm.ToServerUrl().Should().Contain("realm.hy2.io");
|
||||||
|
await realm.StunList.Should().Contain("turn.cloudflare.com:3478");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class Hysteria2FmtTests
|
||||||
|
{
|
||||||
|
// "The hostname and optional port of the server. If the port is omitted, it defaults to 443."
|
||||||
|
// -- https://v2.hysteria.network/docs/developers/URI-Scheme/
|
||||||
|
// A ':' with no digits after it is an omitted port too, per RFC 3986 'port = *DIGIT'.
|
||||||
|
[Test]
|
||||||
|
[Arguments("hysteria2://password@hy2.example/")]
|
||||||
|
[Arguments("hysteria2://password@hy2.example")]
|
||||||
|
[Arguments("hysteria2://password@hy2.example:/")]
|
||||||
|
[Arguments("hy2://password@hy2.example/?sni=real.example")]
|
||||||
|
public async Task ResolveConfig_WithoutPort_ShouldDefaultTo443(string shareUri)
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Hysteria2);
|
||||||
|
await resolved.Address.Should().BeEqualTo("hy2.example");
|
||||||
|
await resolved.Port.Should().BeEqualTo(443);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithoutPort_ShouldProduceAValidProfile()
|
||||||
|
{
|
||||||
|
// Uri.Port is -1 for an unregistered scheme with no port, and ProfileItem.IsValid rejects
|
||||||
|
// any port outside 1..65535 - so the default is what keeps such a link usable at all.
|
||||||
|
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.IsValid().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitPort_ShouldKeepIt()
|
||||||
|
{
|
||||||
|
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example:8443/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Port.Should().BeEqualTo(8443);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_WithExplicitZeroPort_ShouldNotApplyTheDefault()
|
||||||
|
{
|
||||||
|
// Uri.Port is 0 here, not -1: the port is present, it is just not a usable one. Treating
|
||||||
|
// it as "omitted" would silently move the endpoint to :443, so it stays invalid instead.
|
||||||
|
var resolved = FmtHandler.ResolveConfig("hysteria2://password@hy2.example:0/", out _);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved!.Port.Should().BeEqualTo(0);
|
||||||
|
await resolved.IsValid().Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using ServiceLib.Tests.CoreConfig;
|
||||||
|
|
||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class InnerFmtTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task ToUriAndResolve_ShouldRoundTripPolicyGroupReferences()
|
||||||
|
{
|
||||||
|
var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a");
|
||||||
|
var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b");
|
||||||
|
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "group-1", "group-1",
|
||||||
|
[childA.IndexId, childB.IndexId]);
|
||||||
|
group.SetProtocolExtra(group.GetProtocolExtra() with { SubChildItems = "original-sub" });
|
||||||
|
|
||||||
|
var uri = InnerFmt.ToUri([group, childA, childB]);
|
||||||
|
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
await uri.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
var resolved = InnerFmt.Resolve(uri!, "sub-123");
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved.Should().HaveCount(3);
|
||||||
|
|
||||||
|
var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks);
|
||||||
|
var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks);
|
||||||
|
var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks);
|
||||||
|
|
||||||
|
await resolvedGroup.ConfigType.Should().BeEqualTo(EConfigType.PolicyGroup);
|
||||||
|
await resolvedGroup.GetProtocolExtra().SubChildItems.Should().BeEqualTo("sub-123");
|
||||||
|
await resolvedGroup.GetProtocolExtra().ChildItems.Should().BeEqualTo($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class ShareUriQueryTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments("ob%41fs")]
|
||||||
|
[Arguments("66%ff")]
|
||||||
|
[Arguments("100%")]
|
||||||
|
public async Task GetShareUriAndResolveConfig_QueryValueWithPercent_ShouldSurviveTheRoundTrip(string obfsPassword)
|
||||||
|
{
|
||||||
|
var source = new ProfileItem
|
||||||
|
{
|
||||||
|
ConfigType = EConfigType.Hysteria2,
|
||||||
|
Remarks = "percent demo",
|
||||||
|
Address = "hy2.example",
|
||||||
|
Port = 8443,
|
||||||
|
Password = "pw",
|
||||||
|
};
|
||||||
|
source.SetProtocolExtra(new ProtocolExtraItem { SalamanderPass = obfsPassword, });
|
||||||
|
|
||||||
|
var uri = FmtHandler.GetShareUri(source);
|
||||||
|
|
||||||
|
await uri.Should().NotBeNull();
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(uri!, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {uri}, msg: {msg}");
|
||||||
|
await resolved!.GetProtocolExtra().SalamanderPass.Should().BeEqualTo(obfsPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 3986 lists '=' among the sub-delimiters a query value may carry, so only the first one
|
||||||
|
// separates the key from the value. Splitting on every '=' discarded the pair outright.
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_QueryValueWithUnescapedEquals_ShouldNotBeDropped()
|
||||||
|
{
|
||||||
|
const string shareUri = "hysteria2://pw@hy2.example:8443/?ech=AAj+DQAEAAAAAA==&sni=real.example";
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.EchConfigList.Should().BeEqualTo("AAj+DQAEAAAAAA==");
|
||||||
|
await resolved.Sni.Should().BeEqualTo("real.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical SIP002 percent-encodes the plugin argument, and that is what this client's own
|
||||||
|
// exporter emits. This covers the non-canonical spelling instead: the options are a ';'
|
||||||
|
// separated list of 'key=value' pairs, so left unescaped the value always carries '='.
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_NonCanonicalSip002PluginWithLiteralEquals_ShouldConfigureObfs()
|
||||||
|
{
|
||||||
|
const string shareUri =
|
||||||
|
"ss://YWVzLTEyOC1nY206cGFzczEyMw==@1.2.3.4:8388/?plugin=obfs-local;obfs=http;obfs-host=example.com#ss";
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.ConfigType.Should().BeEqualTo(EConfigType.Shadowsocks);
|
||||||
|
await resolved.GetTransportExtra().Host.Should().BeEqualTo("example.com");
|
||||||
|
await resolved.GetTransportExtra().RawHeaderType.Should().BeEqualTo(Global.RawHeaderHttp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_EscapedQueryValue_ShouldStillDecodeExactlyOnce()
|
||||||
|
{
|
||||||
|
const string shareUri = "hysteria2://pw@hy2.example:8443/?ech=AAj%2BDQAEAAAAAA%3D%3D&obfs=salamander&obfs-password=a%20b";
|
||||||
|
|
||||||
|
var resolved = FmtHandler.ResolveConfig(shareUri, out var msg);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull().Because($"uri: {shareUri}, msg: {msg}");
|
||||||
|
await resolved!.EchConfigList.Should().BeEqualTo("AAj+DQAEAAAAAA==");
|
||||||
|
await resolved.GetProtocolExtra().SalamanderPass.Should().BeEqualTo("a b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace ServiceLib.Tests.Fmt;
|
||||||
|
|
||||||
|
public class WireguardFmtTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task ResolveConfig_ShouldParsePeersAndIgnoreInlineComments()
|
||||||
|
{
|
||||||
|
const string config =
|
||||||
|
"""
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = interface-private-key
|
||||||
|
Address = 10.0.0.2/32, fd00::2/128 ; inline comment
|
||||||
|
MTU = 1420
|
||||||
|
DNS = 2001::db8::53, 1.2.3.4, 2001:db8::54
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = peer-public-key
|
||||||
|
PresharedKey = peer-preshared-key
|
||||||
|
Reserved = 1, 2, 3 # inline comment
|
||||||
|
Endpoint = [2001:db8::1]:51820 # inline comment
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = peer-public-key-2
|
||||||
|
Endpoint = example.com:12345
|
||||||
|
""";
|
||||||
|
|
||||||
|
var resolved = WireguardFmt.ResolveConfig(config);
|
||||||
|
|
||||||
|
await resolved.Should().NotBeNull();
|
||||||
|
await resolved.Should().HaveCount(2);
|
||||||
|
|
||||||
|
var first = resolved![0];
|
||||||
|
await first.Address.Should().BeEqualTo("2001:db8::1");
|
||||||
|
await first.Port.Should().BeEqualTo(51820);
|
||||||
|
await first.Password.Should().BeEqualTo("interface-private-key");
|
||||||
|
await first.GetProtocolExtra().WgReserved.Should().BeEqualTo("1, 2, 3");
|
||||||
|
await first.GetProtocolExtra().WgInterfaceAddress.Should().BeEqualTo("10.0.0.2/32, fd00::2/128");
|
||||||
|
await first.GetProtocolExtra().WgMtu.Should().BeEqualTo(1420);
|
||||||
|
await first.GetProtocolExtra().WgDns.Should().BeEqualTo("2001::db8::53, 1.2.3.4, 2001:db8::54");
|
||||||
|
|
||||||
|
var second = resolved[1];
|
||||||
|
await second.Address.Should().BeEqualTo("example.com");
|
||||||
|
await second.Port.Should().BeEqualTo(12345);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
global using System.Collections.Concurrent;
|
||||||
|
global using System.Diagnostics;
|
||||||
|
global using System.Net;
|
||||||
|
global using System.Net.NetworkInformation;
|
||||||
|
global using System.Net.Sockets;
|
||||||
|
global using System.Reflection;
|
||||||
|
global using System.Runtime.InteropServices;
|
||||||
|
global using System.Security.Cryptography;
|
||||||
|
global using System.Text;
|
||||||
|
global using System.Text.Encodings.Web;
|
||||||
|
global using System.Text.Json;
|
||||||
|
global using System.Text.Json.Nodes;
|
||||||
|
global using System.Text.Json.Serialization;
|
||||||
|
global using System.Text.RegularExpressions;
|
||||||
|
global using ReactiveUI;
|
||||||
|
global using ServiceLib.Base;
|
||||||
|
global using ServiceLib.Common;
|
||||||
|
global using ServiceLib.Enums;
|
||||||
|
global using ServiceLib.Events;
|
||||||
|
global using ServiceLib.Handler;
|
||||||
|
global using ServiceLib.Handler.Builder;
|
||||||
|
global using ServiceLib.Handler.Fmt;
|
||||||
|
global using ServiceLib.Handler.SysProxy;
|
||||||
|
global using ServiceLib.Helper;
|
||||||
|
global using ServiceLib.Manager;
|
||||||
|
global using ServiceLib.Models.CoreConfigs;
|
||||||
|
global using ServiceLib.Models.Configs;
|
||||||
|
global using ServiceLib.Models.Dto;
|
||||||
|
global using ServiceLib.Models.Entities;
|
||||||
|
global using ServiceLib.Resx;
|
||||||
|
global using ServiceLib.Services;
|
||||||
|
global using ServiceLib.Services.CoreConfig;
|
||||||
|
global using ServiceLib.Services.Statistics;
|
||||||
|
global using SQLite;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace ServiceLib.Tests.Helper;
|
||||||
|
|
||||||
|
public class HttpRequestHeadersHelperTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task TryParse_ShouldAcceptEmptySettingsForExistingSubscriptions()
|
||||||
|
{
|
||||||
|
foreach (var json in new string?[] { null, "", " \r\n ", "{}" })
|
||||||
|
{
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
|
||||||
|
await headers.Count.Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TryParse_ShouldPreserveValuesAndUseCaseInsensitiveNames()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"X-hwid": "my_test_device",
|
||||||
|
"Authorization": "Bearer test:token",
|
||||||
|
"accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Empty": ""
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
|
||||||
|
await headers["x-HWID"].Should().BeEqualTo("my_test_device");
|
||||||
|
await headers["AUTHORIZATION"].Should().BeEqualTo("Bearer test:token");
|
||||||
|
await headers["Accept"].Should().BeEqualTo("application/json");
|
||||||
|
await headers["Content-Type"].Should().BeEqualTo("application/json");
|
||||||
|
await headers["X-Empty"].Should().BeEqualTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments("not-json")]
|
||||||
|
[Arguments("null")]
|
||||||
|
[Arguments("[]")]
|
||||||
|
[Arguments("{\"X-Test\": 1}")]
|
||||||
|
[Arguments("{\"X-Test\": null}")]
|
||||||
|
[Arguments("{\"X-Test\": [\"one\", \"two\"]}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\", \"X-Test\": \"two\"}")]
|
||||||
|
[Arguments("{\"Accept\": \"one\", \"accept\": \"two\"}")]
|
||||||
|
[Arguments("{\"Bad Header\": \"value\"}")]
|
||||||
|
[Arguments("{\"Bad:Header\": \"value\"}")]
|
||||||
|
[Arguments("{\"\": \"value\"}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\\r\\nInjected: two\"}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\\nInjected: two\"}")]
|
||||||
|
[Arguments("{\"X-Test\": \"one\\u0000two\"}")]
|
||||||
|
public async Task TryParse_ShouldRejectInvalidHeadersWithoutReturningPartialSettings(string json)
|
||||||
|
{
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeFalse();
|
||||||
|
await headers.Count.Should().BeEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task RequestHeaders_ShouldSurviveDatabaseMigrationAndEditing()
|
||||||
|
{
|
||||||
|
using var database = new SQLiteConnection(":memory:", false);
|
||||||
|
database.Execute("CREATE TABLE SubItem (Id TEXT PRIMARY KEY, Remarks TEXT, Url TEXT)");
|
||||||
|
database.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?)", "existing", "Existing", "https://example.com/sub");
|
||||||
|
database.CreateTable<SubItem>();
|
||||||
|
|
||||||
|
var item = database.Find<SubItem>("existing");
|
||||||
|
await HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var oldHeaders).Should().BeTrue();
|
||||||
|
await oldHeaders.Count.Should().BeEqualTo(0);
|
||||||
|
|
||||||
|
item.RequestHeaders = "{\"X-hwid\":\"my_device\"}";
|
||||||
|
database.Update(item);
|
||||||
|
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders);
|
||||||
|
|
||||||
|
item.RequestHeaders = "";
|
||||||
|
database.Update(item);
|
||||||
|
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace ServiceLib.Tests.Manager;
|
||||||
|
|
||||||
|
public class CoreManagerTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(ECoreType.sing_box)]
|
||||||
|
[Arguments(ECoreType.mihomo)]
|
||||||
|
[Arguments(ECoreType.Xray)]
|
||||||
|
public async Task ShouldRunAsSudo_TunLaunchOnNonWindows_RequiresElevation(ECoreType coreType)
|
||||||
|
{
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShouldRunAsSudo_NonTunLaunch_ShouldNotElevate()
|
||||||
|
{
|
||||||
|
// Regression guard for the macOS TUN failure: the elevation decision must follow
|
||||||
|
// the context snapshot that generated the config. A launch whose snapshot has TUN
|
||||||
|
// disabled must never elevate, and a launch whose snapshot has TUN enabled must
|
||||||
|
// elevate regardless of later changes to the live config.
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.sing_box, isNonWindows: true).Should().BeFalse();
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: false, ECoreType.Xray, isNonWindows: true).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ShouldRunAsSudo_OnWindows_ShouldNotElevate()
|
||||||
|
{
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: true, ECoreType.sing_box, isNonWindows: false).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(ECoreType.v2fly)]
|
||||||
|
[Arguments(ECoreType.hysteria)]
|
||||||
|
[Arguments(null)]
|
||||||
|
public async Task ShouldRunAsSudo_UnsupportedCoreType_ShouldNotElevate(ECoreType? coreType)
|
||||||
|
{
|
||||||
|
await CoreManager.ShouldRunAsSudo(isTunLaunch: true, coreType, isNonWindows: true).Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="TUnit" />
|
||||||
|
<PackageReference Include="TUnit.Assertions.Should" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ServiceLib\ServiceLib.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="TUnit.Assertions.Should" />
|
||||||
|
<Using Include="TUnit.Assertions.Should.Extensions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
namespace ServiceLib.Tests.Services;
|
||||||
|
|
||||||
|
public class DownloadServiceHeadersTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Arguments(false, false)]
|
||||||
|
[Arguments(false, true)]
|
||||||
|
[Arguments(true, false)]
|
||||||
|
[Arguments(true, true)]
|
||||||
|
public async Task TryDownloadString_ShouldSendCustomHeadersThroughBothDownloaders(bool useProxy, bool failFirstRequest)
|
||||||
|
{
|
||||||
|
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
|
||||||
|
await using var server = new SubscriptionHttpServer(failFirstRequest);
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"accept": "application/json",
|
||||||
|
"user-agent": "CustomSubscriptionClient/1.0",
|
||||||
|
"authorization": "Bearer test-token",
|
||||||
|
"X-hwid": "test-device",
|
||||||
|
"Cookie": "session=test",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
|
||||||
|
var service = new DownloadService { AcceptHeader = "*/*", RequestHeaders = headers };
|
||||||
|
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
|
||||||
|
{
|
||||||
|
UserName = "user",
|
||||||
|
Password = "password"
|
||||||
|
}.Uri;
|
||||||
|
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
|
||||||
|
|
||||||
|
var content = await service.TryDownloadString(uri.AbsoluteUri, proxy, "OriginalClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue();
|
||||||
|
foreach (var request in server.Requests)
|
||||||
|
{
|
||||||
|
await request["Accept"].Should().BeEqualTo("application/json");
|
||||||
|
await request["User-Agent"].Should().BeEqualTo("CustomSubscriptionClient/1.0");
|
||||||
|
await request["Authorization"].Should().BeEqualTo("Bearer test-token");
|
||||||
|
await request["X-hwid"].Should().BeEqualTo("test-device");
|
||||||
|
await request["Cookie"].Should().BeEqualTo("session=test");
|
||||||
|
await request["Content-Type"].Should().BeEqualTo("application/json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Arguments(false)]
|
||||||
|
[Arguments(true)]
|
||||||
|
public async Task TryDownloadString_ShouldKeepDefaultAcceptUserAgentAndBasicAuth(bool failFirstRequest)
|
||||||
|
{
|
||||||
|
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
|
||||||
|
await using var server = new SubscriptionHttpServer(failFirstRequest);
|
||||||
|
var service = new DownloadService { AcceptHeader = "*/*" };
|
||||||
|
var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri;
|
||||||
|
|
||||||
|
var content = await service.TryDownloadString(uri.AbsoluteUri, (IWebProxy?)null, "ExistingClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
foreach (var request in server.Requests)
|
||||||
|
{
|
||||||
|
await request["Accept"].Should().BeEqualTo("*/*");
|
||||||
|
await request["User-Agent"].Should().BeEqualTo("ExistingClient/1.0");
|
||||||
|
await request["Authorization"].Should().BeEqualTo("Basic dXNlcjpwYXNzd29yZA==");
|
||||||
|
await request.ContainsKey("X-hwid").Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TryDownloadString_ShouldNotShareHeadersWithOtherDownloads()
|
||||||
|
{
|
||||||
|
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
|
||||||
|
await using var server = new SubscriptionHttpServer();
|
||||||
|
var subscription = new DownloadService
|
||||||
|
{
|
||||||
|
AcceptHeader = "*/*",
|
||||||
|
RequestHeaders = new Dictionary<string, string> { ["X-hwid"] = "first-device" }
|
||||||
|
};
|
||||||
|
var ordinaryDownload = new DownloadService();
|
||||||
|
|
||||||
|
await (await subscription.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
await (await ordinaryDownload.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
|
||||||
|
|
||||||
|
var requests = server.Requests.ToArray();
|
||||||
|
await requests.Length.Should().BeEqualTo(2);
|
||||||
|
await requests[0]["X-hwid"].Should().BeEqualTo("first-device");
|
||||||
|
await requests[1].ContainsKey("X-hwid").Should().BeFalse();
|
||||||
|
await requests[1].ContainsKey("Accept").Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SubscriptionHttpServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public const string Body = "subscription-test-content";
|
||||||
|
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||||
|
private readonly CancellationTokenSource _cancellation = new();
|
||||||
|
private readonly Task _serverTask;
|
||||||
|
private readonly bool _failFirstRequest;
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
public ConcurrentQueue<Dictionary<string, string>> Requests { get; } = new();
|
||||||
|
|
||||||
|
public SubscriptionHttpServer(bool failFirstRequest = false)
|
||||||
|
{
|
||||||
|
_failFirstRequest = failFirstRequest;
|
||||||
|
_listener.Start();
|
||||||
|
Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/subscription";
|
||||||
|
_serverTask = ServeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ServeAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = _cancellation.Token;
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
using var client = await _listener.AcceptTcpClientAsync(cancellationToken);
|
||||||
|
await using var stream = client.GetStream();
|
||||||
|
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
|
||||||
|
var requestLine = await reader.ReadLineAsync(cancellationToken);
|
||||||
|
if (requestLine == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
while (await reader.ReadLineAsync(cancellationToken) is { Length: > 0 } line)
|
||||||
|
{
|
||||||
|
var separator = line.IndexOf(':');
|
||||||
|
var name = line.Substring(0, separator);
|
||||||
|
var value = line.Substring(separator + 1).Trim();
|
||||||
|
headers[name] = headers.TryGetValue(name, out var previous) ? $"{previous}, {value}" : value;
|
||||||
|
}
|
||||||
|
Requests.Enqueue(headers);
|
||||||
|
|
||||||
|
var status = _failFirstRequest && Requests.Count == 1 ? "503 Service Unavailable" : "200 OK";
|
||||||
|
var body = requestLine.StartsWith("HEAD ") ? "" : Body;
|
||||||
|
var response = $"HTTP/1.1 {status}\r\nContent-Length: {Body.Length}\r\nConnection: close\r\n\r\n{body}";
|
||||||
|
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _cancellation.CancelAsync();
|
||||||
|
_listener.Stop();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _serverTask;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
global using System.Buffers.Binary;
|
||||||
|
global using System.Diagnostics;
|
||||||
|
global using System.Net;
|
||||||
|
global using System.Net.Sockets;
|
||||||
|
global using System.Text;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
namespace ServiceLib.UdpTest;
|
||||||
|
|
||||||
|
public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposable
|
||||||
|
{
|
||||||
|
private TcpClient _tcpClient;
|
||||||
|
private UdpClient _udpClient;
|
||||||
|
private IPEndPoint _relayEndPoint;
|
||||||
|
|
||||||
|
private bool _initialized = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send UDP data to a remote endpoint (IP address)
|
||||||
|
/// </summary>
|
||||||
|
public async Task SendAsync(IPEndPoint remote, byte[] data)
|
||||||
|
{
|
||||||
|
var addrData = new Socks5AddressData
|
||||||
|
{
|
||||||
|
AddressType = remote.Address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
? Socks5AddressData.AddrTypeIPv4
|
||||||
|
: Socks5AddressData.AddrTypeIPv6,
|
||||||
|
Host = remote.Address.ToString(),
|
||||||
|
Port = (ushort)remote.Port
|
||||||
|
};
|
||||||
|
var packet = BuildSocks5UdpPacket(addrData, data);
|
||||||
|
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send UDP data to a remote endpoint (domain name or IP address)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">Domain name or IP address</param>
|
||||||
|
/// <param name="port">Port number</param>
|
||||||
|
/// <param name="data">Data to send</param>
|
||||||
|
public async Task SendAsync(string host, ushort port, byte[] data)
|
||||||
|
{
|
||||||
|
var addrData = new Socks5AddressData();
|
||||||
|
|
||||||
|
// Try to parse as IP address first
|
||||||
|
if (IPAddress.TryParse(host, out var ipAddr))
|
||||||
|
{
|
||||||
|
addrData.AddressType = ipAddr.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
? Socks5AddressData.AddrTypeIPv4
|
||||||
|
: Socks5AddressData.AddrTypeIPv6;
|
||||||
|
addrData.Host = ipAddr.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Treat as domain name
|
||||||
|
addrData.AddressType = Socks5AddressData.AddrTypeDomain;
|
||||||
|
addrData.Host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
addrData.Port = port;
|
||||||
|
|
||||||
|
var packet = BuildSocks5UdpPacket(addrData, data);
|
||||||
|
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Receive UDP data from remote endpoint
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token to cancel the receive operation</param>
|
||||||
|
/// <returns>Remote endpoint information and received data</returns>
|
||||||
|
public async Task<(Socks5RemoteEndpoint Remote, byte[] Data)> ReceiveAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var (remote, payload) = ParseSocks5UdpPacket(result.Buffer);
|
||||||
|
return (remote, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a remote endpoint that can be either an IP address or a domain name
|
||||||
|
/// </summary>
|
||||||
|
public class Socks5RemoteEndpoint(string host, ushort port, bool isDomain)
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = host;
|
||||||
|
public ushort Port { get; set; } = port;
|
||||||
|
public bool IsDomain { get; set; } = isDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildSocks5UdpPacket(Socks5AddressData addressData, byte[] data)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
|
||||||
|
// RSV (2 bytes) + FRAG (1 byte) - Reserved and Fragment fields
|
||||||
|
ms.WriteByte(0x00);
|
||||||
|
ms.WriteByte(0x00);
|
||||||
|
ms.WriteByte(0x00);
|
||||||
|
|
||||||
|
// Write address (ATYP + address + port)
|
||||||
|
ms.Write(addressData.ToBytes());
|
||||||
|
|
||||||
|
// User data payload
|
||||||
|
ms.Write(data);
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Socks5RemoteEndpoint Remote, byte[] Data) ParseSocks5UdpPacket(byte[] packet)
|
||||||
|
{
|
||||||
|
if (packet.Length < 10) // Minimum length: RSV(2) + FRAG(1) + ATYP(1) + IPv4(4) + Port(2) = 10
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
// RSV (2 bytes) - Reserved field, skip
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
// FRAG (1 byte) - Fragment number, currently only support 0 (no fragmentation)
|
||||||
|
var frag = packet[offset++];
|
||||||
|
if (frag != 0x00)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException("SOCKS5 UDP fragmentation is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATYP (1 byte) - Address type
|
||||||
|
var addressType = packet[offset++];
|
||||||
|
|
||||||
|
string host;
|
||||||
|
int addressLength;
|
||||||
|
bool isDomain;
|
||||||
|
|
||||||
|
switch (addressType)
|
||||||
|
{
|
||||||
|
case Socks5AddressData.AddrTypeIPv4:
|
||||||
|
if (packet.Length < offset + 4)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: IPv4 address incomplete");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipv4Bytes = new byte[4];
|
||||||
|
Array.Copy(packet, offset, ipv4Bytes, 0, 4);
|
||||||
|
host = new IPAddress(ipv4Bytes).ToString();
|
||||||
|
addressLength = 4;
|
||||||
|
isDomain = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Socks5AddressData.AddrTypeIPv6:
|
||||||
|
if (packet.Length < offset + 16)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: IPv6 address incomplete");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipv6Bytes = new byte[16];
|
||||||
|
Array.Copy(packet, offset, ipv6Bytes, 0, 16);
|
||||||
|
host = new IPAddress(ipv6Bytes).ToString();
|
||||||
|
addressLength = 16;
|
||||||
|
isDomain = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Socks5AddressData.AddrTypeDomain:
|
||||||
|
if (packet.Length < offset + 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: domain length missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainLength = packet[offset++];
|
||||||
|
if (packet.Length < offset + domainLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: domain incomplete");
|
||||||
|
}
|
||||||
|
|
||||||
|
host = Encoding.ASCII.GetString(packet, offset, domainLength);
|
||||||
|
addressLength = domainLength;
|
||||||
|
isDomain = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unsupported SOCKS5 address type: {addressType}");
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += addressLength;
|
||||||
|
|
||||||
|
// Port (2 bytes, big-endian)
|
||||||
|
if (packet.Length < offset + 2)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid SOCKS5 UDP packet: port incomplete");
|
||||||
|
}
|
||||||
|
|
||||||
|
var port = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(offset, 2));
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
// Data (remaining bytes)
|
||||||
|
var dataLength = packet.Length - offset;
|
||||||
|
var data = new byte[dataLength];
|
||||||
|
if (dataLength > 0)
|
||||||
|
{
|
||||||
|
Array.Copy(packet, offset, data, 0, dataLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create remote endpoint without DNS resolution
|
||||||
|
var remote = new Socks5RemoteEndpoint(host, port, isDomain);
|
||||||
|
return (remote, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_tcpClient.Dispose();
|
||||||
|
_udpClient.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region SOCKS5 Connection Handling
|
||||||
|
|
||||||
|
private const byte Socks5Version = 0x05;
|
||||||
|
private const byte SocksCmdUdpAssociate = 0x03;
|
||||||
|
|
||||||
|
public async Task<bool> EstablishUdpAssociationAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
|
||||||
|
_tcpClient = new TcpClient();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _tcpClient.ConnectAsync(socks5Host, socks5TcpPort, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcpControlStream = _tcpClient.GetStream();
|
||||||
|
|
||||||
|
byte[] handshakeRequest = [Socks5Version, 0x01, 0x00];
|
||||||
|
await tcpControlStream.WriteAsync(handshakeRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
var handshakeResponse = new byte[2];
|
||||||
|
if (await tcpControlStream.ReadAsync(handshakeResponse, cancellationToken).ConfigureAwait(false) < 2 ||
|
||||||
|
handshakeResponse[0] != Socks5Version || handshakeResponse[1] != 0x00)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientAddrForSocks = new Socks5AddressData
|
||||||
|
{
|
||||||
|
AddressType = Socks5AddressData.AddrTypeIPv4,
|
||||||
|
Host = "0.0.0.0",
|
||||||
|
Port = 0
|
||||||
|
};
|
||||||
|
using var udpAssociateReqMs = new MemoryStream();
|
||||||
|
udpAssociateReqMs.WriteByte(Socks5Version);
|
||||||
|
udpAssociateReqMs.WriteByte(SocksCmdUdpAssociate);
|
||||||
|
udpAssociateReqMs.WriteByte(0x00);
|
||||||
|
udpAssociateReqMs.Write(clientAddrForSocks.ToBytes());
|
||||||
|
await tcpControlStream.WriteAsync(udpAssociateReqMs.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var verRepRsv = new byte[3];
|
||||||
|
if (await tcpControlStream.ReadAsync(verRepRsv, cancellationToken).ConfigureAwait(false) < 3 ||
|
||||||
|
verRepRsv[0] != Socks5Version || verRepRsv[1] != 0x00)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyRelaySocksAddr =
|
||||||
|
await Socks5AddressData.ParseAsync(tcpControlStream, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (proxyRelaySocksAddr == null || !IPAddress.TryParse(proxyRelaySocksAddr.Host, out var proxyRelayIp))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_relayEndPoint = new IPEndPoint(proxyRelayIp, proxyRelaySocksAddr.Port);
|
||||||
|
_initialized = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion SOCKS5 Connection Handling
|
||||||
|
|
||||||
|
#region SOCKS5 Address Handling
|
||||||
|
|
||||||
|
private class Socks5AddressData
|
||||||
|
{
|
||||||
|
public const byte AddrTypeIPv4 = 0x01;
|
||||||
|
public const byte AddrTypeDomain = 0x03;
|
||||||
|
public const byte AddrTypeIPv6 = 0x04;
|
||||||
|
|
||||||
|
public byte AddressType { get; set; }
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
public ushort Port { get; set; }
|
||||||
|
|
||||||
|
public byte[] ToBytes()
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
ms.WriteByte(AddressType);
|
||||||
|
switch (AddressType)
|
||||||
|
{
|
||||||
|
case AddrTypeIPv4:
|
||||||
|
if (IPAddress.TryParse(Host, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
ms.Write(ip.GetAddressBytes(), 0, 4);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ms.Write([0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AddrTypeDomain:
|
||||||
|
if (string.IsNullOrEmpty(Host))
|
||||||
|
{
|
||||||
|
ms.WriteByte(0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var domainBytes = Encoding.ASCII.GetBytes(Host);
|
||||||
|
ms.WriteByte((byte)domainBytes.Length);
|
||||||
|
ms.Write(domainBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AddrTypeIPv6:
|
||||||
|
if (IPAddress.TryParse(Host, out var ip6) && ip6.AddressFamily == AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
ms.Write(ip6.GetAddressBytes(), 0, 16);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ms.Write(new byte[16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"SOCKS5 address type {AddressType} not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var portBytes = new byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(portBytes, Port);
|
||||||
|
ms.Write(portBytes);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Socks5AddressData?> ParseAsync(Stream stream, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var addr = new Socks5AddressData();
|
||||||
|
var typeByte = new byte[1];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await stream.ReadAsync(typeByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr.AddressType = typeByte[0];
|
||||||
|
switch (addr.AddressType)
|
||||||
|
{
|
||||||
|
case AddrTypeIPv4:
|
||||||
|
var ipv4Bytes = new byte[4];
|
||||||
|
if (await stream.ReadAsync(ipv4Bytes.AsMemory(0, 4), ct).ConfigureAwait(false) < 4)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr.Host = new IPAddress(ipv4Bytes).ToString();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AddrTypeDomain:
|
||||||
|
var lenByte = new byte[1];
|
||||||
|
if (await stream.ReadAsync(lenByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lenByte[0] == 0)
|
||||||
|
{
|
||||||
|
addr.Host = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var domainBytes = new byte[lenByte[0]];
|
||||||
|
if (await stream.ReadAsync(domainBytes.AsMemory(0, domainBytes.Length), ct)
|
||||||
|
.ConfigureAwait(false) < domainBytes.Length)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr.Host = Encoding.ASCII.GetString(domainBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AddrTypeIPv6:
|
||||||
|
var ipv6Bytes = new byte[16];
|
||||||
|
if (await stream.ReadAsync(ipv6Bytes.AsMemory(0, 16), ct).ConfigureAwait(false) < 16)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr.Host = new IPAddress(ipv6Bytes).ToString();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var portBytes = new byte[2];
|
||||||
|
if (await stream.ReadAsync(portBytes.AsMemory(0, 2), ct).ConfigureAwait(false) < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addr.Port = BinaryPrimitives.ReadUInt16BigEndian(portBytes);
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion SOCKS5 Address Handling
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace ServiceLib.UdpTest.Tester;
|
||||||
|
|
||||||
|
public class DnsService : IUdpTest
|
||||||
|
{
|
||||||
|
private const int DnsDefaultPort = 53;
|
||||||
|
private const string DnsDefaultServer = "8.8.8.8"; // Google Public DNS
|
||||||
|
|
||||||
|
private static readonly byte[] DnsQueryPacket =
|
||||||
|
[
|
||||||
|
// Header: ID=0x1234, Standard query with RD set, QDCOUNT=1
|
||||||
|
0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
// Question: www.google.com, Type A, Class IN
|
||||||
|
0x03, 0x77, 0x77, 0x77, 0x06, 0x67, 0x6F, 0x6F,
|
||||||
|
0x67, 0x6C, 0x65, 0x03, 0x63, 0x6F, 0x6D, 0x00,
|
||||||
|
0x00, 0x01, 0x00, 0x01
|
||||||
|
];
|
||||||
|
|
||||||
|
public byte[] BuildUdpRequestPacket()
|
||||||
|
{
|
||||||
|
return (byte[])DnsQueryPacket.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyAndExtractUdpResponse(byte[] dnsResponseBytes)
|
||||||
|
{
|
||||||
|
if (dnsResponseBytes.Length < 12)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check transaction ID (should match 0x1234)
|
||||||
|
var transactionId = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(0, 2));
|
||||||
|
if (transactionId != 0x1234)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check flags - should be a response (QR=1)
|
||||||
|
var flags = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(2, 2));
|
||||||
|
if ((flags & 0x8000) == 0)
|
||||||
|
{
|
||||||
|
return false; // Not a response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check response code (RCODE) - should be 0 (no error)
|
||||||
|
if ((flags & 0x000F) != 0)
|
||||||
|
{
|
||||||
|
return false; // DNS error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check answer count
|
||||||
|
var answerCount = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(6, 2));
|
||||||
|
if (answerCount == 0)
|
||||||
|
{
|
||||||
|
return false; // No answers
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetDefaultTargetPort()
|
||||||
|
{
|
||||||
|
return DnsDefaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDefaultTargetHost()
|
||||||
|
{
|
||||||
|
return DnsDefaultServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ServiceLib.UdpTest.Tester;
|
||||||
|
|
||||||
|
public interface IUdpTest
|
||||||
|
{
|
||||||
|
public byte[] BuildUdpRequestPacket();
|
||||||
|
|
||||||
|
public bool VerifyAndExtractUdpResponse(byte[] udpResponseBytes);
|
||||||
|
|
||||||
|
public ushort GetDefaultTargetPort();
|
||||||
|
|
||||||
|
public string GetDefaultTargetHost();
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
namespace ServiceLib.UdpTest.Tester;
|
||||||
|
|
||||||
|
public class McBeService : IUdpTest
|
||||||
|
{
|
||||||
|
private const int McBeDefaultPort = 19132;
|
||||||
|
private const string McBeDefaultServer = "pms.mc-complex.com";
|
||||||
|
|
||||||
|
// 0x01 | client alive time in ms (unsigned long long) | magic | client GUID
|
||||||
|
private static readonly byte[] McBeQueryPacket =
|
||||||
|
[
|
||||||
|
// 0x01
|
||||||
|
0x01,
|
||||||
|
// Client alive time (1000 ms)
|
||||||
|
0x27, 0xC4, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
// Magic
|
||||||
|
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
|
||||||
|
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78,
|
||||||
|
// Client GUID (random 16 bytes)
|
||||||
|
0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D, 0x1F, 0x4E,
|
||||||
|
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE, 0xF5, 0x4B
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly byte[] McBeMagicBytes =
|
||||||
|
[
|
||||||
|
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
|
||||||
|
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly List<string> ValidGameModes =
|
||||||
|
[
|
||||||
|
"Survival",
|
||||||
|
"Creative",
|
||||||
|
"Adventure",
|
||||||
|
"Spectator"
|
||||||
|
];
|
||||||
|
|
||||||
|
public byte[] BuildUdpRequestPacket()
|
||||||
|
{
|
||||||
|
return (byte[])McBeQueryPacket.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyAndExtractUdpResponse(byte[] mcbeResponseBytes)
|
||||||
|
{
|
||||||
|
// 0x1c | client alive time in ms (recorded from previous ping) |
|
||||||
|
// server GUID | Magic | string length | Edition
|
||||||
|
//
|
||||||
|
// Edition Example:
|
||||||
|
//
|
||||||
|
// MCPE;Dedicated Server;527;1.19.1;0;10;13253860892328930865;Bedrock level;Survival;1;19132;19133;
|
||||||
|
if (mcbeResponseBytes.Length < 48)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mcbeResponseBytes[0] != 0x1C)
|
||||||
|
{
|
||||||
|
return false; // Invalid packet type
|
||||||
|
}
|
||||||
|
var pongMagic = mcbeResponseBytes.Skip(17).Take(16).ToArray();
|
||||||
|
if (!pongMagic.SequenceEqual(McBeMagicBytes))
|
||||||
|
{
|
||||||
|
return false; // Magic bytes do not match
|
||||||
|
}
|
||||||
|
var stringLength = (ushort)((mcbeResponseBytes[33] << 8) | mcbeResponseBytes[34]);
|
||||||
|
var stringData = Encoding.UTF8.GetString(mcbeResponseBytes.Skip(35).Take(stringLength).ToArray());
|
||||||
|
var stringParts = stringData.Split(';');
|
||||||
|
// check Game Mode str
|
||||||
|
var gameMode = stringParts.Length > 8 ? stringParts[8] : "";
|
||||||
|
if (!ValidGameModes.Contains(gameMode))
|
||||||
|
{
|
||||||
|
return false; // Invalid game mode
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetDefaultTargetPort()
|
||||||
|
{
|
||||||
|
return McBeDefaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDefaultTargetHost()
|
||||||
|
{
|
||||||
|
return McBeDefaultServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace ServiceLib.UdpTest.Tester;
|
||||||
|
|
||||||
|
public class NtpService : IUdpTest
|
||||||
|
{
|
||||||
|
private const int NtpDefaultPort = 123;
|
||||||
|
private const string NtpDefaultServer = "pool.ntp.org";
|
||||||
|
|
||||||
|
public byte[] BuildUdpRequestPacket()
|
||||||
|
{
|
||||||
|
var ntpReq = new byte[48];
|
||||||
|
ntpReq[0] = 0x23; // LI=0, VN=4, Mode=3
|
||||||
|
return ntpReq;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyAndExtractUdpResponse(byte[] ntpResponseBytes)
|
||||||
|
{
|
||||||
|
if (ntpResponseBytes.Length < 48)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((ntpResponseBytes[0] & 0x07) != 4)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetDefaultTargetPort()
|
||||||
|
{
|
||||||
|
return NtpDefaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDefaultTargetHost()
|
||||||
|
{
|
||||||
|
return NtpDefaultServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace ServiceLib.UdpTest.Tester;
|
||||||
|
|
||||||
|
public class StunService : IUdpTest
|
||||||
|
{
|
||||||
|
private const int StunDefaultPort = 3478;
|
||||||
|
private const string StunDefaultServer = "stun.voztovoice.org";
|
||||||
|
|
||||||
|
private static readonly byte[] StunBindingRequestPacket =
|
||||||
|
[
|
||||||
|
// STUN Binding Request
|
||||||
|
0x00, 0x01, // Message Type: Binding Request (0x0001)
|
||||||
|
0x00, 0x00, // Message Length: 0 (no attributes)
|
||||||
|
0x21, 0x12, 0xA4, 0x42, // Magic Cookie: 0x2112A442
|
||||||
|
// Transaction ID: 96 bits (12 bytes) random
|
||||||
|
0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D,
|
||||||
|
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE,
|
||||||
|
];
|
||||||
|
|
||||||
|
public byte[] BuildUdpRequestPacket()
|
||||||
|
{
|
||||||
|
return (byte[])StunBindingRequestPacket.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyAndExtractUdpResponse(byte[] stunResponseBytes)
|
||||||
|
{
|
||||||
|
if (stunResponseBytes.Length < 20)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stunResponseBytes.Length >= 2)
|
||||||
|
{
|
||||||
|
var messageType = (stunResponseBytes[0] << 8) | stunResponseBytes[1];
|
||||||
|
if (messageType is 0x0101 or 0x0111)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetDefaultTargetPort()
|
||||||
|
{
|
||||||
|
return StunDefaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDefaultTargetHost()
|
||||||
|
{
|
||||||
|
return StunDefaultServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using ServiceLib.UdpTest.Tester;
|
||||||
|
|
||||||
|
namespace ServiceLib.UdpTest;
|
||||||
|
|
||||||
|
public class UdpTestService
|
||||||
|
{
|
||||||
|
private const string DefaultUdpTestType = "ntp";
|
||||||
|
private readonly IUdpTest _udpTest;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, Func<IUdpTest>> UdpTestFactories =
|
||||||
|
new Dictionary<string, Func<IUdpTest>>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["ntp"] = () => new NtpService(),
|
||||||
|
["dns"] = () => new DnsService(),
|
||||||
|
["stun"] = () => new StunService(),
|
||||||
|
["mcbe"] = () => new McBeService(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private UdpTestService(IUdpTest udpTest)
|
||||||
|
{
|
||||||
|
_udpTest = udpTest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UdpTestService Create(string? udpTestType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(udpTestType))
|
||||||
|
{
|
||||||
|
return new UdpTestService(UdpTestFactories[DefaultUdpTestType]());
|
||||||
|
}
|
||||||
|
|
||||||
|
return UdpTestFactories.TryGetValue(udpTestType, out var factory)
|
||||||
|
? new UdpTestService(factory())
|
||||||
|
: new UdpTestService(UdpTestFactories[DefaultUdpTestType]());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UdpTestService CreateFromTarget(string? udpTestTarget, out string targetServerHost)
|
||||||
|
{
|
||||||
|
var parts = udpTestTarget?.Split(':', 2);
|
||||||
|
var udpTestType = parts?.Length > 0 ? parts[0] : DefaultUdpTestType;
|
||||||
|
|
||||||
|
var udpService = Create(udpTestType);
|
||||||
|
targetServerHost = parts?.Length > 1 && !string.IsNullOrEmpty(parts[1])
|
||||||
|
? parts[1]
|
||||||
|
: udpService._udpTest.GetDefaultTargetHost();
|
||||||
|
|
||||||
|
return udpService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string host, ushort port) ParseHostAndPort(string targetServerHost)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(targetServerHost))
|
||||||
|
{
|
||||||
|
return (_udpTest.GetDefaultTargetHost(), _udpTest.GetDefaultTargetPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle IPv6 format: [::1]:port or [2001:db8::1]:port
|
||||||
|
if (targetServerHost.StartsWith('['))
|
||||||
|
{
|
||||||
|
var closeBracketIndex = targetServerHost.IndexOf(']');
|
||||||
|
if (closeBracketIndex > 0)
|
||||||
|
{
|
||||||
|
var host = targetServerHost.Substring(1, closeBracketIndex - 1);
|
||||||
|
if (closeBracketIndex < targetServerHost.Length - 1 && targetServerHost[closeBracketIndex + 1] == ':')
|
||||||
|
{
|
||||||
|
var portStr = targetServerHost.Substring(closeBracketIndex + 2);
|
||||||
|
if (ushort.TryParse(portStr, out var port))
|
||||||
|
{
|
||||||
|
return (host, port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (host, _udpTest.GetDefaultTargetPort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle IPv4 or domain format: 1.1.1.1:53 or exam.com:333
|
||||||
|
var lastColonIndex = targetServerHost.LastIndexOf(':');
|
||||||
|
if (lastColonIndex > 0)
|
||||||
|
{
|
||||||
|
var host = targetServerHost.Substring(0, lastColonIndex);
|
||||||
|
var portStr = targetServerHost.Substring(lastColonIndex + 1);
|
||||||
|
if (ushort.TryParse(portStr, out var port))
|
||||||
|
{
|
||||||
|
return (host, port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No port specified, use default
|
||||||
|
return (targetServerHost, _udpTest.GetDefaultTargetPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TimeSpan> SendUdpRequestAsync(string targetServerHost, int socks5Port, TimeSpan operationTimeout)
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(operationTimeout);
|
||||||
|
var cancellationToken = cts.Token;
|
||||||
|
var udpRequestPacket = _udpTest.BuildUdpRequestPacket();
|
||||||
|
if (udpRequestPacket == null || udpRequestPacket.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to build UDP request packet.");
|
||||||
|
}
|
||||||
|
using var channel = new Socks5UdpChannel("127.0.0.1", socks5Port);
|
||||||
|
if (!await channel.EstablishUdpAssociationAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to establish UDP association with SOCKS5 proxy.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (targetHost, targetPort) = ParseHostAndPort(targetServerHost);
|
||||||
|
|
||||||
|
byte[] udpReceiveResult = null;
|
||||||
|
|
||||||
|
// Get minimum round trip time from two attempts
|
||||||
|
var roundTripTime = TimeSpan.MaxValue;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stopwatch = new Stopwatch();
|
||||||
|
stopwatch.Start();
|
||||||
|
await channel.SendAsync(targetHost, targetPort, udpRequestPacket).ConfigureAwait(false);
|
||||||
|
var (_, receiveResult) = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
udpReceiveResult = receiveResult;
|
||||||
|
|
||||||
|
var currentRoundTripTime = stopwatch.Elapsed;
|
||||||
|
if (currentRoundTripTime < roundTripTime)
|
||||||
|
{
|
||||||
|
roundTripTime = currentRoundTripTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (attempt == 1 && roundTripTime == TimeSpan.MaxValue)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2)
|
||||||
|
{
|
||||||
|
throw new Exception("Received NTP response is too short.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (udpReceiveResult != null && _udpTest.VerifyAndExtractUdpResponse(udpReceiveResult))
|
||||||
|
{
|
||||||
|
return roundTripTime;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to verify and extract UDP response.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
namespace ServiceLib.Base;
|
||||||
|
|
||||||
|
public class BulkObservableCollection<T> : ObservableCollection<T>
|
||||||
|
{
|
||||||
|
private bool _suppressNotification;
|
||||||
|
|
||||||
|
public BulkObservableCollection()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BulkObservableCollection(IEnumerable<T> collection) : base(collection) { }
|
||||||
|
public BulkObservableCollection(List<T> list) : base(list) { }
|
||||||
|
|
||||||
|
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_suppressNotification)
|
||||||
|
{
|
||||||
|
base.OnCollectionChanged(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_suppressNotification)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddRange(IEnumerable<T>? collection)
|
||||||
|
{
|
||||||
|
if (collection == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppressNotification = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in collection)
|
||||||
|
{
|
||||||
|
Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressNotification = false;
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Replace(T oldItem, T newItem)
|
||||||
|
{
|
||||||
|
var index = Items.IndexOf(oldItem);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Items[index] = newItem;
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(
|
||||||
|
NotifyCollectionChangedAction.Replace,
|
||||||
|
newItem,
|
||||||
|
oldItem,
|
||||||
|
index));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplaceRange(IEnumerable<T>? collection)
|
||||||
|
{
|
||||||
|
_suppressNotification = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
if (collection != null)
|
||||||
|
{
|
||||||
|
foreach (var item in collection)
|
||||||
|
{
|
||||||
|
Items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressNotification = false;
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ServiceLib.Base;
|
||||||
|
|
||||||
|
public interface ICloseable
|
||||||
|
{
|
||||||
|
public event EventHandler? RequestClose;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ServiceLib.Base;
|
||||||
|
|
||||||
|
public interface IWindowDialog
|
||||||
|
{
|
||||||
|
public Task<bool> ShowDialogAsync<TViewModel>(TViewModel vm)
|
||||||
|
where TViewModel : class;
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
using ReactiveUI;
|
|
||||||
|
|
||||||
namespace ServiceLib.Base;
|
namespace ServiceLib.Base;
|
||||||
|
|
||||||
public class MyReactiveObject : ReactiveObject
|
public class MyReactiveObject : ReactiveObject
|
||||||
{
|
{
|
||||||
protected static Config? _config;
|
protected static Config? _config;
|
||||||
protected Func<EViewAction, object?, Task<bool>>? _updateView;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
|
||||||
|
|
||||||
public class AesUtils
|
|
||||||
{
|
|
||||||
private const int KeySize = 256; // AES-256
|
|
||||||
private const int IvSize = 16; // AES block size
|
|
||||||
private const int Iterations = 10000;
|
|
||||||
private static readonly byte[] Salt = Encoding.ASCII.GetBytes("saltysalt".PadRight(16, ' '));
|
|
||||||
private static readonly string DefaultPassword = Utils.GetMd5(Utils.GetHomePath() + "AesUtils");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text">Plain text</param>
|
|
||||||
/// <param name="password">Password for key derivation or direct key in ASCII bytes</param>
|
|
||||||
/// <returns>Base64 encoded cipher text with IV</returns>
|
|
||||||
public static string Encrypt(string text, string? password = null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(text))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var plaintext = Encoding.UTF8.GetBytes(text);
|
|
||||||
var key = GetKey(password);
|
|
||||||
var iv = GenerateIv();
|
|
||||||
|
|
||||||
using var aes = Aes.Create();
|
|
||||||
aes.Key = key;
|
|
||||||
aes.IV = iv;
|
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
|
||||||
ms.Write(iv, 0, iv.Length);
|
|
||||||
|
|
||||||
using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
|
|
||||||
{
|
|
||||||
cs.Write(plaintext, 0, plaintext.Length);
|
|
||||||
cs.FlushFinalBlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
var cipherTextWithIv = ms.ToArray();
|
|
||||||
return Convert.ToBase64String(cipherTextWithIv);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cipherTextWithIv">Base64 encoded cipher text with IV</param>
|
|
||||||
/// <param name="password">Password for key derivation or direct key in ASCII bytes</param>
|
|
||||||
/// <returns>Plain text</returns>
|
|
||||||
public static string Decrypt(string cipherTextWithIv, string? password = null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(cipherTextWithIv))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var cipherTextWithIvBytes = Convert.FromBase64String(cipherTextWithIv);
|
|
||||||
var key = GetKey(password);
|
|
||||||
|
|
||||||
var iv = new byte[IvSize];
|
|
||||||
Buffer.BlockCopy(cipherTextWithIvBytes, 0, iv, 0, IvSize);
|
|
||||||
|
|
||||||
var cipherText = new byte[cipherTextWithIvBytes.Length - IvSize];
|
|
||||||
Buffer.BlockCopy(cipherTextWithIvBytes, IvSize, cipherText, 0, cipherText.Length);
|
|
||||||
|
|
||||||
using var aes = Aes.Create();
|
|
||||||
aes.Key = key;
|
|
||||||
aes.IV = iv;
|
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
|
||||||
using (var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Write))
|
|
||||||
{
|
|
||||||
cs.Write(cipherText, 0, cipherText.Length);
|
|
||||||
cs.FlushFinalBlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
var plainText = ms.ToArray();
|
|
||||||
return Encoding.UTF8.GetString(plainText);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] GetKey(string? password)
|
|
||||||
{
|
|
||||||
if (password.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
password = DefaultPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var pbkdf2 = new Rfc2898DeriveBytes(password, Salt, Iterations, HashAlgorithmName.SHA256);
|
|
||||||
return pbkdf2.GetBytes(KeySize / 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] GenerateIv()
|
|
||||||
{
|
|
||||||
var randomNumber = new byte[IvSize];
|
|
||||||
|
|
||||||
using var rng = RandomNumberGenerator.Create();
|
|
||||||
rng.GetBytes(randomNumber);
|
|
||||||
return randomNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for country code utilities
|
||||||
|
/// </summary>
|
||||||
|
public static class CountryExtension
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Country code to emoji flag mapping for common countries
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> CountryEmojiMap = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Asia
|
||||||
|
{ "CN", "🇨🇳" }, // China
|
||||||
|
{ "HK", "🇭🇰" }, // Hong Kong
|
||||||
|
{ "TW", "🇹🇼" }, // Taiwan
|
||||||
|
{ "JP", "🇯🇵" }, // Japan
|
||||||
|
{ "SG", "🇸🇬" }, // Singapore
|
||||||
|
{ "KR", "🇰🇷" }, // South Korea
|
||||||
|
{ "TH", "🇹🇭" }, // Thailand
|
||||||
|
{ "VN", "🇻🇳" }, // Vietnam
|
||||||
|
{ "ID", "🇮🇩" }, // Indonesia
|
||||||
|
{ "PH", "🇵🇭" }, // Philippines
|
||||||
|
{ "MY", "🇲🇾" }, // Malaysia
|
||||||
|
{ "IN", "🇮🇳" }, // India
|
||||||
|
{ "PK", "🇵🇰" }, // Pakistan
|
||||||
|
{ "BD", "🇧🇩" }, // Bangladesh
|
||||||
|
{ "LK", "🇱🇰" }, // Sri Lanka
|
||||||
|
{ "KH", "🇰🇭" }, // Cambodia
|
||||||
|
{ "LA", "🇱🇦" }, // Laos
|
||||||
|
{ "MM", "🇲🇲" }, // Myanmar
|
||||||
|
|
||||||
|
// Americas
|
||||||
|
{ "US", "🇺🇸" }, // United States
|
||||||
|
{ "CA", "🇨🇦" }, // Canada
|
||||||
|
{ "MX", "🇲🇽" }, // Mexico
|
||||||
|
{ "BR", "🇧🇷" }, // Brazil
|
||||||
|
{ "AR", "🇦🇷" }, // Argentina
|
||||||
|
{ "CL", "🇨🇱" }, // Chile
|
||||||
|
{ "CO", "🇨🇴" }, // Colombia
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
{ "GB", "🇬🇧" }, // United Kingdom
|
||||||
|
{ "DE", "🇩🇪" }, // Germany
|
||||||
|
{ "FR", "🇫🇷" }, // France
|
||||||
|
{ "IT", "🇮🇹" }, // Italy
|
||||||
|
{ "ES", "🇪🇸" }, // Spain
|
||||||
|
{ "RU", "🇷🇺" }, // Russia
|
||||||
|
{ "NL", "🇳🇱" }, // Netherlands
|
||||||
|
{ "CH", "🇨🇭" }, // Switzerland
|
||||||
|
{ "SE", "🇸🇪" }, // Sweden
|
||||||
|
{ "NO", "🇳🇴" }, // Norway
|
||||||
|
{ "DK", "🇩🇰" }, // Denmark
|
||||||
|
{ "FI", "🇫🇮" }, // Finland
|
||||||
|
{ "PL", "🇵🇱" }, // Poland
|
||||||
|
{ "CZ", "🇨🇿" }, // Czech Republic
|
||||||
|
{ "AT", "🇦🇹" }, // Austria
|
||||||
|
{ "GR", "🇬🇷" }, // Greece
|
||||||
|
{ "PT", "🇵🇹" }, // Portugal
|
||||||
|
{ "TR", "🇹🇷" }, // Turkey
|
||||||
|
{ "UA", "🇺🇦" }, // Ukraine
|
||||||
|
{ "RO", "🇷🇴" }, // Romania
|
||||||
|
|
||||||
|
// Middle East & Central Asia
|
||||||
|
{ "AE", "🇦🇪" }, // United Arab Emirates
|
||||||
|
{ "SA", "🇸🇦" }, // Saudi Arabia
|
||||||
|
{ "IL", "🇮🇱" }, // Israel
|
||||||
|
{ "KZ", "🇰🇿" }, // Kazakhstan
|
||||||
|
|
||||||
|
// Oceania
|
||||||
|
{ "AU", "🇦🇺" }, // Australia
|
||||||
|
{ "NZ", "🇳🇿" }, // New Zealand
|
||||||
|
|
||||||
|
// Africa
|
||||||
|
{ "ZA", "🇿🇦" }, // South Africa
|
||||||
|
{ "EG", "🇪🇬" }, // Egypt
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts country code to flag emoji using predefined mapping
|
||||||
|
/// Example: "US" -> "🇺🇸", "CN" -> "🇨🇳"
|
||||||
|
/// </summary>
|
||||||
|
public static string? CountryToEmoji(this string? countryCode)
|
||||||
|
{
|
||||||
|
if (countryCode.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CountryEmojiMap.GetValueOrDefault(countryCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
|
||||||
|
|
||||||
public class DesUtils
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Encrypt
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="text"></param>
|
|
||||||
/// /// <param name="key"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static string Encrypt(string? text, string? key = null)
|
|
||||||
{
|
|
||||||
if (text.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
GetKeyIv(key ?? GetDefaultKey(), out var rgbKey, out var rgbIv);
|
|
||||||
var dsp = DES.Create();
|
|
||||||
using var memStream = new MemoryStream();
|
|
||||||
using var cryStream = new CryptoStream(memStream, dsp.CreateEncryptor(rgbKey, rgbIv), CryptoStreamMode.Write);
|
|
||||||
using var sWriter = new StreamWriter(cryStream);
|
|
||||||
sWriter.Write(text);
|
|
||||||
sWriter.Flush();
|
|
||||||
cryStream.FlushFinalBlock();
|
|
||||||
memStream.Flush();
|
|
||||||
return Convert.ToBase64String(memStream.GetBuffer(), 0, (int)memStream.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decrypt
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="encryptText"></param>
|
|
||||||
/// <param name="key"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static string Decrypt(string? encryptText, string? key = null)
|
|
||||||
{
|
|
||||||
if (encryptText.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
GetKeyIv(key ?? GetDefaultKey(), out var rgbKey, out var rgbIv);
|
|
||||||
var dsp = DES.Create();
|
|
||||||
var buffer = Convert.FromBase64String(encryptText);
|
|
||||||
|
|
||||||
using var memStream = new MemoryStream();
|
|
||||||
using var cryStream = new CryptoStream(memStream, dsp.CreateDecryptor(rgbKey, rgbIv), CryptoStreamMode.Write);
|
|
||||||
cryStream.Write(buffer, 0, buffer.Length);
|
|
||||||
cryStream.FlushFinalBlock();
|
|
||||||
return Encoding.UTF8.GetString(memStream.ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void GetKeyIv(string key, out byte[] rgbKey, out byte[] rgbIv)
|
|
||||||
{
|
|
||||||
if (key.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException("The key cannot be null");
|
|
||||||
}
|
|
||||||
if (key.Length <= 8)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException("The key length cannot be less than 8 characters.");
|
|
||||||
}
|
|
||||||
|
|
||||||
rgbKey = Encoding.ASCII.GetBytes(key.Substring(0, 8));
|
|
||||||
rgbIv = Encoding.ASCII.GetBytes(key.Insert(0, "w").Substring(0, 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetDefaultKey()
|
|
||||||
{
|
|
||||||
return Utils.GetMd5(Utils.GetHomePath() + "DesUtils");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using Downloader;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
|
||||||
|
|
||||||
public class DownloaderHelper
|
|
||||||
{
|
|
||||||
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
|
|
||||||
public static DownloaderHelper Instance => _instance.Value;
|
|
||||||
|
|
||||||
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri uri = new(url);
|
|
||||||
//Authorization Header
|
|
||||||
var headers = new WebHeaderCollection();
|
|
||||||
if (uri.UserInfo.IsNotEmpty())
|
|
||||||
{
|
|
||||||
headers.Add(HttpRequestHeader.Authorization, "Basic " + Utils.Base64Encode(uri.UserInfo));
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadOpt = new DownloadConfiguration()
|
|
||||||
{
|
|
||||||
Timeout = timeout * 1000,
|
|
||||||
MaxTryAgainOnFailover = 2,
|
|
||||||
RequestConfiguration =
|
|
||||||
{
|
|
||||||
Headers = headers,
|
|
||||||
UserAgent = userAgent,
|
|
||||||
Timeout = timeout * 1000,
|
|
||||||
Proxy = webProxy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
|
||||||
downloader.DownloadFileCompleted += (sender, value) =>
|
|
||||||
{
|
|
||||||
if (value.Error != null)
|
|
||||||
{
|
|
||||||
throw value.Error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token).WaitAsync(TimeSpan.FromSeconds(timeout), cts.Token);
|
|
||||||
using StreamReader reader = new(stream);
|
|
||||||
|
|
||||||
downloadOpt = null;
|
|
||||||
|
|
||||||
return await reader.ReadToEndAsync(cts.Token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, string url, IProgress<string> progress, int timeout)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadOpt = new DownloadConfiguration()
|
|
||||||
{
|
|
||||||
Timeout = timeout * 1000,
|
|
||||||
MaxTryAgainOnFailover = 2,
|
|
||||||
RequestConfiguration =
|
|
||||||
{
|
|
||||||
Timeout= timeout * 1000,
|
|
||||||
Proxy = webProxy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var totalDatetime = DateTime.Now;
|
|
||||||
var totalSecond = 0;
|
|
||||||
var hasValue = false;
|
|
||||||
double maxSpeed = 0;
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
|
||||||
//downloader.DownloadStarted += (sender, value) =>
|
|
||||||
//{
|
|
||||||
// if (progress != null)
|
|
||||||
// {
|
|
||||||
// progress.Report("Start download data...");
|
|
||||||
// }
|
|
||||||
//};
|
|
||||||
downloader.DownloadProgressChanged += (sender, value) =>
|
|
||||||
{
|
|
||||||
var ts = DateTime.Now - totalDatetime;
|
|
||||||
if (progress != null && ts.Seconds > totalSecond)
|
|
||||||
{
|
|
||||||
hasValue = true;
|
|
||||||
totalSecond = ts.Seconds;
|
|
||||||
if (value.BytesPerSecondSpeed > maxSpeed)
|
|
||||||
{
|
|
||||||
maxSpeed = value.BytesPerSecondSpeed;
|
|
||||||
var speed = (maxSpeed / 1000 / 1000).ToString("#0.0");
|
|
||||||
progress.Report(speed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
downloader.DownloadFileCompleted += (sender, value) =>
|
|
||||||
{
|
|
||||||
if (progress != null)
|
|
||||||
{
|
|
||||||
if (!hasValue && value.Error != null)
|
|
||||||
{
|
|
||||||
progress.Report(value.Error?.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
//progress.Report("......");
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
cts.CancelAfter(timeout * 1000);
|
|
||||||
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
|
|
||||||
|
|
||||||
downloadOpt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DownloadFileAsync(IWebProxy? webProxy, string url, string fileName, IProgress<double> progress, int timeout)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(url));
|
|
||||||
}
|
|
||||||
if (fileName.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(fileName));
|
|
||||||
}
|
|
||||||
if (File.Exists(fileName))
|
|
||||||
{
|
|
||||||
File.Delete(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadOpt = new DownloadConfiguration()
|
|
||||||
{
|
|
||||||
Timeout = timeout * 1000,
|
|
||||||
MaxTryAgainOnFailover = 2,
|
|
||||||
RequestConfiguration =
|
|
||||||
{
|
|
||||||
Timeout= timeout * 1000,
|
|
||||||
Proxy = webProxy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var progressPercentage = 0;
|
|
||||||
var hasValue = false;
|
|
||||||
await using var downloader = new Downloader.DownloadService(downloadOpt);
|
|
||||||
downloader.DownloadStarted += (sender, value) => progress?.Report(0);
|
|
||||||
downloader.DownloadProgressChanged += (sender, value) =>
|
|
||||||
{
|
|
||||||
hasValue = true;
|
|
||||||
var percent = (int)value.ProgressPercentage;// Convert.ToInt32((totalRead * 1d) / (total * 1d) * 100);
|
|
||||||
if (progressPercentage != percent && percent % 10 == 0)
|
|
||||||
{
|
|
||||||
progressPercentage = percent;
|
|
||||||
progress.Report(percent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
downloader.DownloadFileCompleted += (sender, value) =>
|
|
||||||
{
|
|
||||||
if (progress != null)
|
|
||||||
{
|
|
||||||
if (hasValue && value.Error == null)
|
|
||||||
{
|
|
||||||
progress.Report(101);
|
|
||||||
}
|
|
||||||
else if (value.Error != null)
|
|
||||||
{
|
|
||||||
throw value.Error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource();
|
|
||||||
await downloader.DownloadFileTaskAsync(url, fileName, cts.Token);
|
|
||||||
|
|
||||||
downloadOpt = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
public static class EmbedUtils
|
public static class EmbedUtils
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
|
public static class Extension
|
||||||
|
{
|
||||||
|
public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) || string.IsNullOrEmpty(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsNotEmpty([NotNullWhen(false)] this string? value)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NullIfEmpty(this string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool BeginWithAny(this string s, IEnumerable<char> chars)
|
||||||
|
{
|
||||||
|
if (s.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return chars.Contains(s.First());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWhiteSpace(this string value)
|
||||||
|
{
|
||||||
|
return value.All(char.IsWhiteSpace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<string> NonWhiteSpaceLines(this TextReader reader)
|
||||||
|
{
|
||||||
|
while (reader.ReadLine() is { } line)
|
||||||
|
{
|
||||||
|
if (line.IsWhiteSpace())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string TrimEx(this string? value)
|
||||||
|
{
|
||||||
|
return value?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string RemovePrefix(this string value, char prefix)
|
||||||
|
{
|
||||||
|
return value.StartsWith(prefix) ? value[1..] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string RemovePrefix(this string value, string prefix)
|
||||||
|
{
|
||||||
|
return value.StartsWith(prefix) ? value[prefix.Length..] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string UpperFirstChar(this string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return char.ToUpper(value.First()) + value[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string AppendQuotes(this string value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(value) ? string.Empty : $"\"{value}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int ToInt(this string? value, int defaultValue = 0)
|
||||||
|
{
|
||||||
|
return int.TryParse(value, out var result) ? result : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> AppendEmpty(this IEnumerable<string> source)
|
||||||
|
{
|
||||||
|
return source.Concat(new[] { string.Empty }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsGroupType(this EConfigType configType)
|
||||||
|
{
|
||||||
|
return configType is EConfigType.PolicyGroup or EConfigType.ProxyChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsComplexType(this EConfigType configType)
|
||||||
|
{
|
||||||
|
return configType is EConfigType.Custom or EConfigType.Outbound or EConfigType.PolicyGroup or EConfigType.ProxyChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safely adds elements from a collection to the list. Does nothing if the source is null.
|
||||||
|
/// </summary>
|
||||||
|
public static void AddRangeSafe<T>(this ICollection<T> destination, IEnumerable<T>? source)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
|
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination is List<T> list)
|
||||||
|
{
|
||||||
|
list.AddRange(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
destination.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replace all cross-platform newline characters with the specified string
|
||||||
|
/// </summary>
|
||||||
|
public static string ReplaceLineBreaks(this string input, string replacement)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input))
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// You must replace \r\n first, and then replace the single characters \r and \n.
|
||||||
|
return input.Replace("\r\n", replacement)
|
||||||
|
.Replace("\r", replacement)
|
||||||
|
.Replace("\n", replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<TOutput> HandleSafe<TInput, TOutput>(
|
||||||
|
this Interaction<TInput, TOutput> interaction,
|
||||||
|
TInput input,
|
||||||
|
TOutput defaultValue = default!,
|
||||||
|
[CallerMemberName] string memberName = "",
|
||||||
|
[CallerFilePath] string filePath = "",
|
||||||
|
[CallerLineNumber] int lineNumber = 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await interaction.Handle(input);
|
||||||
|
}
|
||||||
|
catch (UnhandledInteractionException<TInput, TOutput> ex)
|
||||||
|
{
|
||||||
|
var title = $"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}";
|
||||||
|
Logging.SaveLog(title, ex);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var title = $"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}";
|
||||||
|
Logging.SaveLog(title, ex);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
using System.Formats.Tar;
|
using System.Formats.Tar;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
public static class FileManager
|
public static class FileUtils
|
||||||
{
|
{
|
||||||
private static readonly string _tag = "FileManager";
|
private static readonly string _tag = "FileManager";
|
||||||
|
|
||||||
@@ -203,7 +202,7 @@ public static class FileManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DeleteExpiredFiles(string sourceDir, DateTime dtLine)
|
public static void DeleteExpiredFiles(string sourceDir, DateTime dtLine, string? contains = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -215,6 +214,10 @@ public static class FileManager
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (contains.IsNotEmpty() && !file.Name.Contains(contains))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
file.Delete();
|
file.Delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,4 +226,28 @@ public static class FileManager
|
|||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a Linux shell file with the specified contents.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileName"></param>
|
||||||
|
/// <param name="contents"></param>
|
||||||
|
/// <param name="overwrite"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static async Task<string> CreateLinuxShellFile(string fileName, string contents, bool overwrite)
|
||||||
|
{
|
||||||
|
var shFilePath = Utils.GetBinConfigPath(fileName);
|
||||||
|
|
||||||
|
// Check if the file already exists and if we should overwrite it
|
||||||
|
if (!overwrite && File.Exists(shFilePath))
|
||||||
|
{
|
||||||
|
return shFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(shFilePath);
|
||||||
|
await File.WriteAllTextAsync(shFilePath, contents);
|
||||||
|
await Utils.SetLinuxChmod(shFilePath);
|
||||||
|
|
||||||
|
return shFilePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Mime;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// </summary>
|
|
||||||
public class HttpClientHelper
|
|
||||||
{
|
|
||||||
private static readonly Lazy<HttpClientHelper> _instance = new(() =>
|
|
||||||
{
|
|
||||||
SocketsHttpHandler handler = new() { UseCookies = false };
|
|
||||||
HttpClientHelper helper = new(new HttpClient(handler));
|
|
||||||
return helper;
|
|
||||||
});
|
|
||||||
|
|
||||||
public static HttpClientHelper Instance => _instance.Value;
|
|
||||||
private readonly HttpClient httpClient;
|
|
||||||
|
|
||||||
private HttpClientHelper(HttpClient httpClient)
|
|
||||||
{
|
|
||||||
this.httpClient = httpClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> TryGetAsync(string url)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await httpClient.GetAsync(url);
|
|
||||||
return await response.Content.ReadAsStringAsync();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> GetAsync(string url)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await httpClient.GetStringAsync(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> GetAsync(HttpClient client, string url, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await client.GetStringAsync(url, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task PutAsync(string url, Dictionary<string, string> headers)
|
|
||||||
{
|
|
||||||
var jsonContent = JsonUtils.Serialize(headers);
|
|
||||||
var content = new StringContent(jsonContent, Encoding.UTF8, MediaTypeNames.Application.Json);
|
|
||||||
|
|
||||||
await httpClient.PutAsync(url, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task PatchAsync(string url, Dictionary<string, string> headers)
|
|
||||||
{
|
|
||||||
var myContent = JsonUtils.Serialize(headers);
|
|
||||||
var buffer = Encoding.UTF8.GetBytes(myContent);
|
|
||||||
var byteContent = new ByteArrayContent(buffer);
|
|
||||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
||||||
|
|
||||||
await httpClient.PatchAsync(url, byteContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(string url)
|
|
||||||
{
|
|
||||||
await httpClient.DeleteAsync(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task DownloadFileAsync(HttpClient client, string url, string fileName, IProgress<double>? progress, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(url);
|
|
||||||
ArgumentNullException.ThrowIfNull(fileName);
|
|
||||||
if (File.Exists(fileName))
|
|
||||||
{
|
|
||||||
File.Delete(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
throw new Exception(response.StatusCode.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
var total = response.Content.Headers.ContentLength ?? -1L;
|
|
||||||
var canReportProgress = total != -1 && progress != null;
|
|
||||||
|
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
|
||||||
await using var file = File.Create(fileName);
|
|
||||||
var totalRead = 0L;
|
|
||||||
var buffer = new byte[1024 * 1024];
|
|
||||||
var progressPercentage = 0;
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var read = await stream.ReadAsync(buffer, token);
|
|
||||||
totalRead += read;
|
|
||||||
|
|
||||||
if (read == 0)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await file.WriteAsync(buffer.AsMemory(0, read), token);
|
|
||||||
|
|
||||||
if (canReportProgress)
|
|
||||||
{
|
|
||||||
var percent = (int)(100.0 * totalRead / total);
|
|
||||||
//if (progressPercentage != percent && percent % 10 == 0)
|
|
||||||
{
|
|
||||||
progressPercentage = percent;
|
|
||||||
progress?.Report(percent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (canReportProgress)
|
|
||||||
{
|
|
||||||
progress?.Report(101);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DownloadDataAsync4Speed(HttpClient client, string url, IProgress<string> progress, CancellationToken token = default)
|
|
||||||
{
|
|
||||||
if (url.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
throw new Exception(response.StatusCode.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
//var total = response.Content.Headers.ContentLength.HasValue ? response.Content.Headers.ContentLength.Value : -1L;
|
|
||||||
//var canReportProgress = total != -1 && progress != null;
|
|
||||||
|
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
|
||||||
var totalRead = 0L;
|
|
||||||
var buffer = new byte[1024 * 64];
|
|
||||||
var isMoreToRead = true;
|
|
||||||
var progressSpeed = string.Empty;
|
|
||||||
var totalDatetime = DateTime.Now;
|
|
||||||
var totalSecond = 0;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
if (totalRead > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var read = await stream.ReadAsync(buffer, token);
|
|
||||||
|
|
||||||
if (read == 0)
|
|
||||||
{
|
|
||||||
isMoreToRead = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var data = new byte[read];
|
|
||||||
buffer.ToList().CopyTo(0, data, 0, read);
|
|
||||||
|
|
||||||
totalRead += read;
|
|
||||||
|
|
||||||
var ts = DateTime.Now - totalDatetime;
|
|
||||||
if (progress != null && ts.Seconds > totalSecond)
|
|
||||||
{
|
|
||||||
totalSecond = ts.Seconds;
|
|
||||||
var speed = (totalRead * 1d / ts.TotalMilliseconds / 1000).ToString("#0.0");
|
|
||||||
if (progressSpeed != speed)
|
|
||||||
{
|
|
||||||
progressSpeed = speed;
|
|
||||||
progress.Report(speed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (isMoreToRead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
|
||||||
/*
|
|
||||||
* See:
|
|
||||||
* http://stackoverflow.com/questions/6266820/working-example-of-createjobobject-setinformationjobobject-pinvoke-in-net
|
|
||||||
*/
|
|
||||||
|
|
||||||
public sealed class Job : IDisposable
|
|
||||||
{
|
|
||||||
private IntPtr handle = IntPtr.Zero;
|
|
||||||
|
|
||||||
public Job()
|
|
||||||
{
|
|
||||||
handle = CreateJobObject(IntPtr.Zero, null);
|
|
||||||
var extendedInfoPtr = IntPtr.Zero;
|
|
||||||
var info = new JOBOBJECT_BASIC_LIMIT_INFORMATION
|
|
||||||
{
|
|
||||||
LimitFlags = 0x2000
|
|
||||||
};
|
|
||||||
|
|
||||||
var extendedInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
||||||
{
|
|
||||||
BasicLimitInformation = info
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var length = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
|
||||||
extendedInfoPtr = Marshal.AllocHGlobal(length);
|
|
||||||
Marshal.StructureToPtr(extendedInfo, extendedInfoPtr, false);
|
|
||||||
|
|
||||||
if (!SetInformationJobObject(handle, JobObjectInfoType.ExtendedLimitInformation, extendedInfoPtr,
|
|
||||||
(uint)length))
|
|
||||||
{
|
|
||||||
throw new Exception(string.Format("Unable to set information. Error: {0}",
|
|
||||||
Marshal.GetLastWin32Error()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (extendedInfoPtr != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
Marshal.FreeHGlobal(extendedInfoPtr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool AddProcess(IntPtr processHandle)
|
|
||||||
{
|
|
||||||
var succ = AssignProcessToJobObject(handle, processHandle);
|
|
||||||
|
|
||||||
if (!succ)
|
|
||||||
{
|
|
||||||
Logging.SaveLog("Failed to call AssignProcessToJobObject! GetLastError=" + Marshal.GetLastWin32Error());
|
|
||||||
}
|
|
||||||
|
|
||||||
return succ;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool AddProcess(int processId)
|
|
||||||
{
|
|
||||||
return AddProcess(Process.GetProcessById(processId).Handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
#region IDisposable
|
|
||||||
|
|
||||||
private bool disposed;
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
disposed = true;
|
|
||||||
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
// no managed objects to free
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handle != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
CloseHandle(handle);
|
|
||||||
handle = IntPtr.Zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
~Job()
|
|
||||||
{
|
|
||||||
Dispose(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion IDisposable
|
|
||||||
|
|
||||||
#region Interop
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern IntPtr CreateJobObject(IntPtr a, string? lpName);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
private static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoType infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool CloseHandle(IntPtr hObject);
|
|
||||||
|
|
||||||
#endregion Interop
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Helper classes
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
internal struct IO_COUNTERS
|
|
||||||
{
|
|
||||||
public ulong ReadOperationCount;
|
|
||||||
public ulong WriteOperationCount;
|
|
||||||
public ulong OtherOperationCount;
|
|
||||||
public ulong ReadTransferCount;
|
|
||||||
public ulong WriteTransferCount;
|
|
||||||
public ulong OtherTransferCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
internal struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
|
||||||
{
|
|
||||||
public long PerProcessUserTimeLimit;
|
|
||||||
public long PerJobUserTimeLimit;
|
|
||||||
public uint LimitFlags;
|
|
||||||
public UIntPtr MinimumWorkingSetSize;
|
|
||||||
public UIntPtr MaximumWorkingSetSize;
|
|
||||||
public uint ActiveProcessLimit;
|
|
||||||
public UIntPtr Affinity;
|
|
||||||
public uint PriorityClass;
|
|
||||||
public uint SchedulingClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct SECURITY_ATTRIBUTES
|
|
||||||
{
|
|
||||||
public uint nLength;
|
|
||||||
public IntPtr lpSecurityDescriptor;
|
|
||||||
public int bInheritHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
internal struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
|
||||||
{
|
|
||||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
|
||||||
public IO_COUNTERS IoInfo;
|
|
||||||
public UIntPtr ProcessMemoryLimit;
|
|
||||||
public UIntPtr JobMemoryLimit;
|
|
||||||
public UIntPtr PeakProcessMemoryUsed;
|
|
||||||
public UIntPtr PeakJobMemoryUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum JobObjectInfoType
|
|
||||||
{
|
|
||||||
AssociateCompletionPortInformation = 7,
|
|
||||||
BasicLimitInformation = 2,
|
|
||||||
BasicUIRestrictions = 4,
|
|
||||||
EndOfJobTimeInformation = 6,
|
|
||||||
ExtendedLimitInformation = 9,
|
|
||||||
SecurityLimitInformation = 5,
|
|
||||||
GroupInformation = 11
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Helper classes
|
|
||||||
|
|
||||||
@@ -1,23 +1,61 @@
|
|||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
public class JsonUtils
|
public class JsonUtils
|
||||||
{
|
{
|
||||||
private static readonly string _tag = "JsonUtils";
|
private static readonly string _tag = "JsonUtils";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _defaultDeserializeOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _defaultSerializeOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _defaultSerializeNoIndentedOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _nullValueSerializeOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _nullValueSerializeNoIndentedOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonDocumentOptions _defaultDocumentOptions = new()
|
||||||
|
{
|
||||||
|
CommentHandling = JsonCommentHandling.Skip
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DeepCopy
|
/// DeepCopy
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T"></typeparam>
|
/// <typeparam name="T"></typeparam>
|
||||||
/// <param name="obj"></param>
|
/// <param name="obj"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static T DeepCopy<T>(T obj)
|
public static T? DeepCopy<T>(T? obj)
|
||||||
{
|
{
|
||||||
return Deserialize<T>(Serialize(obj, false))!;
|
if (obj is null)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
return Deserialize<T>(Serialize(obj, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,11 +72,7 @@ public class JsonUtils
|
|||||||
{
|
{
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
var options = new JsonSerializerOptions
|
return JsonSerializer.Deserialize<T>(strJson, _defaultDeserializeOptions);
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true
|
|
||||||
};
|
|
||||||
return JsonSerializer.Deserialize<T>(strJson, options);
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -51,7 +85,7 @@ public class JsonUtils
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="strJson"></param>
|
/// <param name="strJson"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static JsonNode? ParseJson(string strJson)
|
public static JsonNode? ParseJson(string? strJson)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -59,7 +93,7 @@ public class JsonUtils
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JsonNode.Parse(strJson);
|
return JsonNode.Parse(strJson, nodeOptions: null, _defaultDocumentOptions);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -84,11 +118,12 @@ public class JsonUtils
|
|||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
var options = new JsonSerializerOptions
|
var options = (nullValue, indented) switch
|
||||||
{
|
{
|
||||||
WriteIndented = indented,
|
(true, true) => _nullValueSerializeOptions,
|
||||||
DefaultIgnoreCondition = nullValue ? JsonIgnoreCondition.Never : JsonIgnoreCondition.WhenWritingNull,
|
(true, false) => _nullValueSerializeNoIndentedOptions,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
(false, true) => _defaultSerializeOptions,
|
||||||
|
_ => _defaultSerializeNoIndentedOptions
|
||||||
};
|
};
|
||||||
result = JsonSerializer.Serialize(obj, options);
|
result = JsonSerializer.Serialize(obj, options);
|
||||||
}
|
}
|
||||||
@@ -105,7 +140,7 @@ public class JsonUtils
|
|||||||
/// <param name="obj"></param>
|
/// <param name="obj"></param>
|
||||||
/// <param name="options"></param>
|
/// <param name="options"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static string Serialize(object? obj, JsonSerializerOptions options)
|
public static string Serialize(object? obj, JsonSerializerOptions? options)
|
||||||
{
|
{
|
||||||
var result = string.Empty;
|
var result = string.Empty;
|
||||||
try
|
try
|
||||||
@@ -114,7 +149,7 @@ public class JsonUtils
|
|||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
result = JsonSerializer.Serialize(obj, options);
|
result = JsonSerializer.Serialize(obj, options ?? _defaultSerializeOptions);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -128,5 +163,8 @@ public class JsonUtils
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="obj"></param>
|
/// <param name="obj"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static JsonNode? SerializeToNode(object? obj) => JsonSerializer.SerializeToNode(obj);
|
public static JsonNode? SerializeToNode(object? obj, JsonSerializerOptions? options = null)
|
||||||
|
{
|
||||||
|
return JsonSerializer.SerializeToNode(obj, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
public static class ProcUtils
|
public static class ProcUtils
|
||||||
@@ -48,7 +46,7 @@ public static class ProcUtils
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RebootAsAdmin(bool blAdmin = true)
|
public static bool RebootAsAdmin(bool blAdmin = true)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -60,123 +58,12 @@ public static class ProcUtils
|
|||||||
FileName = Utils.GetExePath().AppendQuotes(),
|
FileName = Utils.GetExePath().AppendQuotes(),
|
||||||
Verb = blAdmin ? "runas" : null,
|
Verb = blAdmin ? "runas" : null,
|
||||||
};
|
};
|
||||||
_ = Process.Start(startInfo);
|
return Process.Start(startInfo) != null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, ex);
|
Logging.SaveLog(_tag, ex);
|
||||||
}
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task ProcessKill(int pid)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ProcessKill(Process.GetProcessById(pid), false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task ProcessKill(Process? proc, bool review)
|
|
||||||
{
|
|
||||||
if (proc is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
GetProcessKeyInfo(proc, review, out var procId, out var fileName, out var processName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Utils.IsNonWindows())
|
|
||||||
{
|
|
||||||
proc?.Kill(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
proc?.Kill();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
proc?.Close();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
proc?.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(300);
|
|
||||||
await ProcessKillByKeyInfo(review, procId, fileName, processName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void GetProcessKeyInfo(Process? proc, bool review, out int? procId, out string? fileName, out string? processName)
|
|
||||||
{
|
|
||||||
procId = null;
|
|
||||||
fileName = null;
|
|
||||||
processName = null;
|
|
||||||
if (!review)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
procId = proc?.Id;
|
|
||||||
fileName = proc?.MainModule?.FileName;
|
|
||||||
processName = proc?.ProcessName;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ProcessKillByKeyInfo(bool review, int? procId, string? fileName, string? processName)
|
|
||||||
{
|
|
||||||
if (review && procId != null && fileName != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lstProc = Process.GetProcessesByName(processName);
|
|
||||||
foreach (var proc2 in lstProc)
|
|
||||||
{
|
|
||||||
if (proc2.Id == procId)
|
|
||||||
{
|
|
||||||
Logging.SaveLog($"{_tag}, KillProcess not completing the job, procId");
|
|
||||||
await ProcessKill(proc2, false);
|
|
||||||
}
|
|
||||||
if (proc2.MainModule != null && proc2.MainModule?.FileName == fileName)
|
|
||||||
{
|
|
||||||
Logging.SaveLog($"{_tag}, KillProcess not completing the job, fileName");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
using QRCoder;
|
using QRCoder;
|
||||||
|
using QRCoder.Exceptions;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
using ZXing.SkiaSharp;
|
using ZXing.SkiaSharp;
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
public class QRCodeHelper
|
public class QRCodeUtils
|
||||||
{
|
{
|
||||||
public static byte[]? GenQRCode(string? url)
|
public static byte[]? GenQRCode(string? url)
|
||||||
{
|
{
|
||||||
|
if (url.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
using QRCodeGenerator qrGenerator = new();
|
using QRCodeGenerator qrGenerator = new();
|
||||||
using var qrCodeData = qrGenerator.CreateQrCode(url ?? string.Empty, QRCodeGenerator.ECCLevel.Q);
|
DataTooLongException? lastDtle = null;
|
||||||
using PngByteQRCode qrCode = new(qrCodeData);
|
|
||||||
return qrCode.GetGraphic(20);
|
var levels = new[]
|
||||||
|
{
|
||||||
|
QRCodeGenerator.ECCLevel.H,
|
||||||
|
QRCodeGenerator.ECCLevel.Q,
|
||||||
|
QRCodeGenerator.ECCLevel.M,
|
||||||
|
QRCodeGenerator.ECCLevel.L
|
||||||
|
};
|
||||||
|
foreach (var level in levels)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var qrCodeData = qrGenerator.CreateQrCode(url, level);
|
||||||
|
using PngByteQRCode qrCode = new(qrCodeData);
|
||||||
|
return qrCode.GetGraphic(20);
|
||||||
|
}
|
||||||
|
catch (DataTooLongException ex)
|
||||||
|
{
|
||||||
|
lastDtle = ex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastDtle != null)
|
||||||
|
{
|
||||||
|
throw lastDtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string? ParseBarcode(string? fileName)
|
public static string? ParseBarcode(string? fileName)
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
namespace ServiceLib.Common;
|
|
||||||
|
|
||||||
public class SemanticVersion
|
|
||||||
{
|
|
||||||
private readonly int major;
|
|
||||||
private readonly int minor;
|
|
||||||
private readonly int patch;
|
|
||||||
private readonly string version;
|
|
||||||
|
|
||||||
public SemanticVersion(int major, int minor, int patch)
|
|
||||||
{
|
|
||||||
this.major = major;
|
|
||||||
this.minor = minor;
|
|
||||||
this.patch = patch;
|
|
||||||
version = $"{major}.{minor}.{patch}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public SemanticVersion(string? version)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(version))
|
|
||||||
{
|
|
||||||
major = 0;
|
|
||||||
minor = 0;
|
|
||||||
patch = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.version = version.RemovePrefix('v');
|
|
||||||
|
|
||||||
var parts = this.version.Split('.');
|
|
||||||
if (parts.Length == 2)
|
|
||||||
{
|
|
||||||
major = int.Parse(parts.First());
|
|
||||||
minor = int.Parse(parts.Last());
|
|
||||||
patch = 0;
|
|
||||||
}
|
|
||||||
else if (parts.Length is 3 or 4)
|
|
||||||
{
|
|
||||||
major = int.Parse(parts[0]);
|
|
||||||
minor = int.Parse(parts[1]);
|
|
||||||
patch = int.Parse(parts[2]);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Invalid version string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
major = 0;
|
|
||||||
minor = 0;
|
|
||||||
patch = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool Equals(object? obj)
|
|
||||||
{
|
|
||||||
if (obj is SemanticVersion other)
|
|
||||||
{
|
|
||||||
return major == other.major && minor == other.minor && patch == other.patch;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetHashCode()
|
|
||||||
{
|
|
||||||
return major.GetHashCode() ^ minor.GetHashCode() ^ patch.GetHashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Use ToVersionString(string? prefix) instead if possible.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>major.minor.patch</returns>
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ToVersionString(string? prefix = null)
|
|
||||||
{
|
|
||||||
if (prefix == null)
|
|
||||||
{
|
|
||||||
return version;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return $"{prefix}{version}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool operator ==(SemanticVersion v1, SemanticVersion v2)
|
|
||||||
{ return v1.Equals(v2); }
|
|
||||||
|
|
||||||
public static bool operator !=(SemanticVersion v1, SemanticVersion v2)
|
|
||||||
{ return !v1.Equals(v2); }
|
|
||||||
|
|
||||||
public static bool operator >=(SemanticVersion v1, SemanticVersion v2)
|
|
||||||
{ return v1.GreaterEquals(v2); }
|
|
||||||
|
|
||||||
public static bool operator <=(SemanticVersion v1, SemanticVersion v2)
|
|
||||||
{ return v1.LessEquals(v2); }
|
|
||||||
|
|
||||||
#region Private
|
|
||||||
|
|
||||||
private bool GreaterEquals(SemanticVersion other)
|
|
||||||
{
|
|
||||||
if (major < other.major)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else if (major > other.major)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (minor < other.minor)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else if (minor > other.minor)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (patch < other.patch)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else if (patch > other.patch)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool LessEquals(SemanticVersion other)
|
|
||||||
{
|
|
||||||
if (major < other.major)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if (major > other.major)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (minor < other.minor)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if (minor > other.minor)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (patch < other.patch)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if (patch > other.patch)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Private
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
|
||||||
|
|
||||||
public static class StringEx
|
|
||||||
{
|
|
||||||
public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value)
|
|
||||||
{
|
|
||||||
return string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value)
|
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsNotEmpty([NotNullWhen(false)] this string? value)
|
|
||||||
{
|
|
||||||
return !string.IsNullOrEmpty(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool BeginWithAny(this string s, IEnumerable<char> chars)
|
|
||||||
{
|
|
||||||
if (s.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return chars.Contains(s.First());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsWhiteSpace(this string value)
|
|
||||||
{
|
|
||||||
return value.All(char.IsWhiteSpace);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IEnumerable<string> NonWhiteSpaceLines(this TextReader reader)
|
|
||||||
{
|
|
||||||
while (reader.ReadLine() is { } line)
|
|
||||||
{
|
|
||||||
if (line.IsWhiteSpace())
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
yield return line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string TrimEx(this string? value)
|
|
||||||
{
|
|
||||||
return value == null ? string.Empty : value.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string RemovePrefix(this string value, char prefix)
|
|
||||||
{
|
|
||||||
return value.StartsWith(prefix) ? value[1..] : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string RemovePrefix(this string value, string prefix)
|
|
||||||
{
|
|
||||||
return value.StartsWith(prefix) ? value[prefix.Length..] : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string UpperFirstChar(this string value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value))
|
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
return char.ToUpper(value.First()) + value[1..];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string AppendQuotes(this string value)
|
|
||||||
{
|
|
||||||
return string.IsNullOrEmpty(value) ? string.Empty : $"\"{value}\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int ToInt(this string? value, int defaultValue = 0)
|
|
||||||
{
|
|
||||||
return int.TryParse(value, out var result) ? result : defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
using System.Collections.Specialized;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.NetworkInformation;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using System.Text;
|
|
||||||
using CliWrap;
|
using CliWrap;
|
||||||
using CliWrap.Buffered;
|
using CliWrap.Buffered;
|
||||||
|
|
||||||
@@ -17,7 +8,7 @@ public class Utils
|
|||||||
{
|
{
|
||||||
private static readonly string _tag = "Utils";
|
private static readonly string _tag = "Utils";
|
||||||
|
|
||||||
#region 转换函数
|
#region Conversion Functions
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convert to comma-separated string
|
/// Convert to comma-separated string
|
||||||
@@ -59,7 +50,7 @@ public class Utils
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
str = str.Replace(Environment.NewLine, string.Empty);
|
str = str.ReplaceLineBreaks(string.Empty);
|
||||||
return new List<string>(str.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
return new List<string>(str.Split(',', StringSplitOptions.RemoveEmptyEntries));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -85,13 +76,19 @@ public class Utils
|
|||||||
/// Base64 Encode
|
/// Base64 Encode
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="plainText"></param>
|
/// <param name="plainText"></param>
|
||||||
|
/// <param name="removePadding"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static string Base64Encode(string plainText)
|
public static string Base64Encode(string plainText, bool removePadding = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
|
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
|
||||||
return Convert.ToBase64String(plainTextBytes);
|
var base64 = Convert.ToBase64String(plainTextBytes);
|
||||||
|
if (removePadding)
|
||||||
|
{
|
||||||
|
base64 = base64.TrimEnd('=');
|
||||||
|
}
|
||||||
|
return base64;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -112,13 +109,11 @@ public class Utils
|
|||||||
{
|
{
|
||||||
if (plainText.IsNullOrEmpty())
|
if (plainText.IsNullOrEmpty())
|
||||||
{
|
{
|
||||||
return "";
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
plainText = plainText.Trim()
|
plainText = plainText.Trim()
|
||||||
.Replace(Environment.NewLine, "")
|
.ReplaceLineBreaks("")
|
||||||
.Replace("\n", "")
|
|
||||||
.Replace("\r", "")
|
|
||||||
.Replace('_', '/')
|
.Replace('_', '/')
|
||||||
.Replace('-', '+')
|
.Replace('-', '+')
|
||||||
.Replace(" ", "");
|
.Replace(" ", "");
|
||||||
@@ -205,7 +200,9 @@ public class Utils
|
|||||||
var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries);
|
var parts = query[1..].Split('&', StringSplitOptions.RemoveEmptyEntries);
|
||||||
foreach (var part in parts)
|
foreach (var part in parts)
|
||||||
{
|
{
|
||||||
var keyValue = part.Split('=');
|
// Split on the FIRST '=' only: RFC 3986 lists '=' among the sub-delimiters a query
|
||||||
|
// value may carry, so everything after the first one belongs to the value.
|
||||||
|
var keyValue = part.Split('=', 2);
|
||||||
if (keyValue.Length != 2)
|
if (keyValue.Length != 2)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -308,7 +305,10 @@ public class Utils
|
|||||||
public static bool IsBase64String(string? plainText)
|
public static bool IsBase64String(string? plainText)
|
||||||
{
|
{
|
||||||
if (plainText.IsNullOrEmpty())
|
if (plainText.IsNullOrEmpty())
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var buffer = new Span<byte>(new byte[plainText.Length]);
|
var buffer = new Span<byte>(new byte[plainText.Length]);
|
||||||
return Convert.TryFromBase64String(plainText, buffer, out var _);
|
return Convert.TryFromBase64String(plainText, buffer, out var _);
|
||||||
}
|
}
|
||||||
@@ -320,12 +320,190 @@ public class Utils
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return text.Replace(",", ",").Replace(Environment.NewLine, ",");
|
return text.Replace(",", ",")
|
||||||
|
.Replace(" ", "")
|
||||||
|
.ReplaceLineBreaks(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion 转换函数
|
public static string ParseProcess(string text)
|
||||||
|
{
|
||||||
|
if (text.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
if (text.StartsWith('"'))
|
||||||
|
{
|
||||||
|
text = text[1..];
|
||||||
|
}
|
||||||
|
if (text.EndsWith('"'))
|
||||||
|
{
|
||||||
|
text = text[..^1];
|
||||||
|
}
|
||||||
|
return List2String(text.Replace(",", ",")
|
||||||
|
.Replace("\\", "/")
|
||||||
|
.ReplaceLineBreaks(",")
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(x => x.TrimEx())
|
||||||
|
.Where(x => x.IsNotEmpty())
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
#region 数据检查
|
public static List<string> GetEnumNames<TEnum>() where TEnum : Enum
|
||||||
|
{
|
||||||
|
return Enum.GetValues(typeof(TEnum))
|
||||||
|
.Cast<TEnum>()
|
||||||
|
.Select(e => e.ToString())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, List<string>> ParseHostsToDictionary(string? hostsContent)
|
||||||
|
{
|
||||||
|
if (hostsContent.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
var userHostsMap = hostsContent
|
||||||
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(line => line.Trim())
|
||||||
|
// skip full-line comments
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))
|
||||||
|
// ensure line still contains valid parts
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line) && line.Contains(' '))
|
||||||
|
.Select(line => line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.Where(parts => parts.Length >= 2)
|
||||||
|
.GroupBy(parts => parts[0])
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => group.SelectMany(parts => parts.Skip(1)).ToList()
|
||||||
|
);
|
||||||
|
|
||||||
|
return userHostsMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a possibly non-standard URL into scheme, domain, port, and path.
|
||||||
|
/// If parsing fails, the entire input is returned as domain, and others are empty or zero.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url">Input URL or string</param>
|
||||||
|
/// <returns>(domain, scheme, port, path)</returns>
|
||||||
|
public static (string domain, string scheme, int port, string path) ParseUrl(string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return ("", "", 0, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. First, try to parse using the standard Uri class.
|
||||||
|
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
|
||||||
|
{
|
||||||
|
var scheme = uri.Scheme;
|
||||||
|
var domain = uri.Host;
|
||||||
|
var port = uri.IsDefaultPort ? 0 : uri.Port;
|
||||||
|
var path = uri.PathAndQuery;
|
||||||
|
return (domain, scheme, port, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to handle more general cases with a regular expression, including non-standard schemes.
|
||||||
|
// This regex captures the scheme (optional), authority (host+port), and path (optional).
|
||||||
|
var match = Regex.Match(url, @"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):/{2,})?([^/?#]+)([^?#]*)?.*$");
|
||||||
|
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
var scheme = match.Groups[1].Value;
|
||||||
|
var authority = match.Groups[2].Value;
|
||||||
|
var path = match.Groups[3].Value;
|
||||||
|
|
||||||
|
// Remove userinfo from the authority part.
|
||||||
|
var atIndex = authority.LastIndexOf('@');
|
||||||
|
if (atIndex > 0)
|
||||||
|
{
|
||||||
|
authority = authority.Substring(atIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (domain, port) = ParseAuthority(authority);
|
||||||
|
|
||||||
|
// If the parsed domain is empty, it means the authority part is malformed, so trigger the fallback.
|
||||||
|
if (!string.IsNullOrEmpty(domain))
|
||||||
|
{
|
||||||
|
return (domain, scheme, port, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If all of the above fails, execute the final fallback strategy.
|
||||||
|
return (url, "", 0, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper function to parse domain and port from the authority part, with correct handling for IPv6.
|
||||||
|
/// </summary>
|
||||||
|
private static (string domain, int port) ParseAuthority(string authority)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(authority))
|
||||||
|
{
|
||||||
|
return ("", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var port = 0;
|
||||||
|
var domain = authority;
|
||||||
|
|
||||||
|
// Handle IPv6 addresses, e.g., "[2001:db8::1]:443"
|
||||||
|
if (authority.StartsWith('[') && authority.Contains(']'))
|
||||||
|
{
|
||||||
|
var closingBracketIndex = authority.LastIndexOf(']');
|
||||||
|
if (closingBracketIndex < authority.Length - 1 && authority[closingBracketIndex + 1] == ':')
|
||||||
|
{
|
||||||
|
// Port exists
|
||||||
|
var portStr = authority.Substring(closingBracketIndex + 2);
|
||||||
|
if (int.TryParse(portStr, out var portNum))
|
||||||
|
{
|
||||||
|
port = portNum;
|
||||||
|
}
|
||||||
|
domain = authority.Substring(0, closingBracketIndex + 1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No port
|
||||||
|
domain = authority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else // Handle IPv4 or domain names
|
||||||
|
{
|
||||||
|
var lastColonIndex = authority.LastIndexOf(':');
|
||||||
|
// Ensure there are digits after the colon and that this colon is not part of an IPv6 address.
|
||||||
|
if (lastColonIndex > 0 && lastColonIndex < authority.Length - 1 && authority.Substring(lastColonIndex + 1).All(char.IsDigit))
|
||||||
|
{
|
||||||
|
var portStr = authority.Substring(lastColonIndex + 1);
|
||||||
|
if (int.TryParse(portStr, out var portNum))
|
||||||
|
{
|
||||||
|
port = portNum;
|
||||||
|
domain = authority.Substring(0, lastColonIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (domain, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? DomainStrategy4Sbox(string? strategy)
|
||||||
|
{
|
||||||
|
if (strategy is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strategy switch
|
||||||
|
{
|
||||||
|
_ when strategy.StartsWith("UseIPv6") => "prefer_ipv6",
|
||||||
|
_ when strategy.StartsWith("UseIP") => "prefer_ipv4",
|
||||||
|
_ when strategy.StartsWith("ForceIPv6") => "ipv6_only",
|
||||||
|
_ when strategy.StartsWith("ForceIP") => "ipv4_only",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion Conversion Functions
|
||||||
|
|
||||||
|
#region Data Checks
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determine if the input is a number
|
/// Determine if the input is a number
|
||||||
@@ -348,6 +526,13 @@ public class Utils
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(domain);
|
||||||
|
if (ext.IsNotEmpty()
|
||||||
|
&& ext[1..].ToLowerInvariant() is "json" or "txt" or "xml" or "cfg" or "ini" or "log" or "yaml" or "yml" or "toml")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return Uri.CheckHostName(domain) == UriHostNameType.Dns;
|
return Uri.CheckHostName(domain) == UriHostNameType.Dns;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +551,48 @@ public class Utils
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsIpv4(string? ip)
|
||||||
|
{
|
||||||
|
if (ip.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ip = ip.Trim();
|
||||||
|
if (!IPAddress.TryParse(ip, out var address))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
&& ip.Count(c => c == '.') == 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsIpAddress(string? ip)
|
||||||
|
{
|
||||||
|
if (ip.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ip = ip.Trim();
|
||||||
|
|
||||||
|
// First, validate using built-in parser
|
||||||
|
if (!IPAddress.TryParse(ip, out var address))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For IPv4: ensure it has exactly 3 dots (meaning 4 parts)
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
return ip.Count(c => c == '.') == 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For IPv6: TryParse is already strict enough
|
||||||
|
return address.AddressFamily == AddressFamily.InterNetworkV6;
|
||||||
|
}
|
||||||
|
|
||||||
public static Uri? TryUri(string url)
|
public static Uri? TryUri(string url)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -378,36 +605,136 @@ public class Utils
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool TryParseRange(string? input, int min, int max, out int from, out int to)
|
||||||
|
{
|
||||||
|
from = to = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var parts = input.Split('-');
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(parts[0], out from))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
to = from;
|
||||||
|
return from >= min && to <= max;
|
||||||
|
}
|
||||||
|
if (parts.Length != 2
|
||||||
|
|| !int.TryParse(parts[0], out from)
|
||||||
|
|| !int.TryParse(parts[1], out to))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return from >= min && to <= max && from <= to;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseMaxSplit(string? input, int min, int max, out int from, out int to)
|
||||||
|
{
|
||||||
|
from = to = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var parts = input.Split('-');
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(parts[0], out from))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
to = from;
|
||||||
|
return from >= min && to <= max;
|
||||||
|
}
|
||||||
|
if (parts.Length != 2
|
||||||
|
|| !int.TryParse(parts[0], out from)
|
||||||
|
|| !int.TryParse(parts[1], out to))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return from >= min && to <= max && from <= to;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsPrivateNetwork(string ip)
|
public static bool IsPrivateNetwork(string ip)
|
||||||
{
|
{
|
||||||
if (IPAddress.TryParse(ip, out var address))
|
if (IPAddress.TryParse(ip, out var address))
|
||||||
{
|
{
|
||||||
|
// Loopback address check (127.0.0.1 for IPv4, ::1 for IPv6)
|
||||||
|
if (IPAddress.IsLoopback(address))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var ipBytes = address.GetAddressBytes();
|
var ipBytes = address.GetAddressBytes();
|
||||||
if (ipBytes[0] == 10)
|
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
return true;
|
{
|
||||||
if (ipBytes[0] == 172 && ipBytes[1] >= 16 && ipBytes[1] <= 31)
|
// IPv4 private address check
|
||||||
return true;
|
if (ipBytes[0] == 10)
|
||||||
if (ipBytes[0] == 192 && ipBytes[1] == 168)
|
{
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipBytes[0] == 172 && ipBytes[1] >= 16 && ipBytes[1] <= 31)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipBytes[0] == 192 && ipBytes[1] == 168)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
// IPv6 private address check
|
||||||
|
// Link-local address fe80::/10
|
||||||
|
if (ipBytes[0] == 0xfe && (ipBytes[1] & 0xc0) == 0x80)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique local address fc00::/7 (typically fd00::/8)
|
||||||
|
if ((ipBytes[0] & 0xfe) == 0xfc)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private portion in IPv4-mapped addresses ::ffff:0:0/96
|
||||||
|
if (address.IsIPv4MappedToIPv6)
|
||||||
|
{
|
||||||
|
var ipv4Bytes = ipBytes.Skip(12).ToArray();
|
||||||
|
if (ipv4Bytes[0] == 10)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion 数据检查
|
#endregion Data Checks
|
||||||
|
|
||||||
#region 测速
|
#region Speed Test
|
||||||
|
|
||||||
private static bool PortInUse(int port)
|
private static bool PortInUse(int port)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
List<IPEndPoint> lstIpEndPoints = new();
|
var (lstIpEndPoints, lstTcpConns) = GetActiveNetworkInfo();
|
||||||
List<TcpConnectionInformation> lstTcpConns = new();
|
|
||||||
|
|
||||||
lstIpEndPoints.AddRange(IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners());
|
|
||||||
lstIpEndPoints.AddRange(IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners());
|
|
||||||
lstTcpConns.AddRange(IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections());
|
|
||||||
|
|
||||||
if (lstIpEndPoints?.FindIndex(it => it.Port == port) >= 0)
|
if (lstIpEndPoints?.FindIndex(it => it.Port == port) >= 0)
|
||||||
{
|
{
|
||||||
@@ -427,11 +754,11 @@ public class Utils
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int GetFreePort(int defaultPort = 9090)
|
public static int GetFreePort(int defaultPort)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Utils.PortInUse(defaultPort))
|
if (!PortInUse(defaultPort))
|
||||||
{
|
{
|
||||||
return defaultPort;
|
return defaultPort;
|
||||||
}
|
}
|
||||||
@@ -449,9 +776,82 @@ public class Utils
|
|||||||
return 59090;
|
return 59090;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion 测速
|
public static (List<IPEndPoint> endpoints, List<TcpConnectionInformation> connections) GetActiveNetworkInfo()
|
||||||
|
{
|
||||||
|
var endpoints = new List<IPEndPoint>();
|
||||||
|
var connections = new List<TcpConnectionInformation>();
|
||||||
|
|
||||||
#region 杂项
|
try
|
||||||
|
{
|
||||||
|
var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
|
||||||
|
|
||||||
|
endpoints.AddRange(ipGlobalProperties.GetActiveTcpListeners());
|
||||||
|
endpoints.AddRange(ipGlobalProperties.GetActiveUdpListeners());
|
||||||
|
connections.AddRange(ipGlobalProperties.GetActiveTcpConnections());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog(_tag, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (endpoints, connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsLocalIP(string ipAddress)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(ipAddress, out var targetAddress))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
|
||||||
|
.Any(ua => ua.Address.Equals(targetAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ContainsInterfaceName(string inInterfaceName)
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the host holds a globally routable IPv6 address, that is one inside 2000::/3.
|
||||||
|
/// Link-local and unique local addresses are excluded: they never reach the IPv6 internet,
|
||||||
|
/// so a host holding only those has no IPv6 traffic that could bypass the tunnel, and no
|
||||||
|
/// IPv6 path that traffic sent into the tunnel could come back out of.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasGlobalIPv6Address()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(ni => ni.OperationalStatus == OperationalStatus.Up
|
||||||
|
&& ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||||
|
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
|
||||||
|
.Any(ua => IsGlobalUnicastIPv6(ua.Address));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGlobalUnicastIPv6(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.AddressFamily != AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2000::/3 is the only range currently assigned for global unicast, which leaves out
|
||||||
|
// ::1, fe80::/10, fc00::/7 and ff00::/8 in a single test.
|
||||||
|
return (address.GetAddressBytes()[0] & 0xE0) == 0x20;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion Speed Test
|
||||||
|
|
||||||
|
#region Miscellaneous
|
||||||
|
|
||||||
public static bool UpgradeAppExists(out string upgradeFileName)
|
public static bool UpgradeAppExists(out string upgradeFileName)
|
||||||
{
|
{
|
||||||
@@ -527,27 +927,65 @@ public class Utils
|
|||||||
return Guid.TryParse(strSrc, out _);
|
return Guid.TryParse(strSrc, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Dictionary<string, string> GetSystemHosts()
|
private static Dictionary<string, string> GetSystemHosts(string hostFile)
|
||||||
{
|
{
|
||||||
var systemHosts = new Dictionary<string, string>();
|
var systemHosts = new Dictionary<string, string>();
|
||||||
var hostFile = @"C:\Windows\System32\drivers\etc\hosts";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(hostFile))
|
if (!File.Exists(hostFile))
|
||||||
{
|
{
|
||||||
var hosts = File.ReadAllText(hostFile).Replace("\r", "");
|
return systemHosts;
|
||||||
var hostsList = hosts.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
foreach (var host in hostsList)
|
|
||||||
{
|
|
||||||
if (host.StartsWith("#"))
|
|
||||||
continue;
|
|
||||||
var hostItem = host.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
if (hostItem.Length != 2)
|
|
||||||
continue;
|
|
||||||
systemHosts.Add(hostItem.Last(), hostItem.First());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
var hosts = File.ReadAllText(hostFile).Replace("\r", "");
|
||||||
|
var hostsList = hosts.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var host in hostsList)
|
||||||
|
{
|
||||||
|
// Trim whitespace
|
||||||
|
var line = host.Trim();
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (line.IsNullOrEmpty() || line.StartsWith("#"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip inline comments
|
||||||
|
var commentIndex = line.IndexOf('#');
|
||||||
|
if (commentIndex >= 0)
|
||||||
|
{
|
||||||
|
line = line.Substring(0, commentIndex).Trim();
|
||||||
|
}
|
||||||
|
if (line.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostItem = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (hostItem.Length < 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ipAddress = hostItem[0];
|
||||||
|
var domain = hostItem[1];
|
||||||
|
|
||||||
|
// Validate IP address
|
||||||
|
if (!IsIpAddress(ipAddress))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate domain name
|
||||||
|
if (domain.IsNullOrEmpty() || domain.Length > 255)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
systemHosts[domain] = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemHosts;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -557,6 +995,29 @@ public class Utils
|
|||||||
return systemHosts;
|
return systemHosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, string> GetSystemHosts()
|
||||||
|
{
|
||||||
|
if (IsWindows())
|
||||||
|
{
|
||||||
|
var hosts = GetSystemHosts(@"C:\Windows\System32\drivers\etc\hosts");
|
||||||
|
var hostsIcs = GetSystemHosts(@"C:\Windows\System32\drivers\etc\hosts.ics");
|
||||||
|
|
||||||
|
foreach (var (key, value) in hostsIcs)
|
||||||
|
{
|
||||||
|
hosts[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLinux() || IsMacOS())
|
||||||
|
{
|
||||||
|
return GetSystemHosts("/etc/hosts");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg)
|
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg)
|
||||||
{
|
{
|
||||||
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null);
|
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null);
|
||||||
@@ -582,7 +1043,7 @@ public class Utils
|
|||||||
var result = await cmd.ExecuteBufferedAsync();
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
return result.StandardOutput.ToString();
|
return result.StandardOutput ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
Logging.SaveLog(result.ToString() ?? "");
|
Logging.SaveLog(result.ToString() ?? "");
|
||||||
@@ -595,7 +1056,7 @@ public class Utils
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion 杂项
|
#endregion Miscellaneous
|
||||||
|
|
||||||
#region TempPath
|
#region TempPath
|
||||||
|
|
||||||
@@ -796,17 +1257,33 @@ public class Utils
|
|||||||
|
|
||||||
#region Platform
|
#region Platform
|
||||||
|
|
||||||
public static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
[SupportedOSPlatformGuard("windows")]
|
||||||
|
public static bool IsWindows() => OperatingSystem.IsWindows();
|
||||||
|
|
||||||
public static bool IsLinux() => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
[SupportedOSPlatformGuard("linux")]
|
||||||
|
public static bool IsLinux() => OperatingSystem.IsLinux();
|
||||||
|
|
||||||
public static bool IsOSX() => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
[SupportedOSPlatformGuard("macos")]
|
||||||
|
public static bool IsMacOS() => OperatingSystem.IsMacOS();
|
||||||
|
|
||||||
public static bool IsNonWindows() => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
[UnsupportedOSPlatformGuard("windows")]
|
||||||
|
public static bool IsNonWindows() => !OperatingSystem.IsWindows();
|
||||||
|
|
||||||
public static string GetExeName(string name)
|
public static string GetExeName(string name)
|
||||||
{
|
{
|
||||||
return IsWindows() ? $"{name}.exe" : name;
|
if (name.IsNullOrEmpty() || IsNonWindows())
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return $"{name}.exe";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsAdministrator()
|
public static bool IsAdministrator()
|
||||||
@@ -816,18 +1293,45 @@ public class Utils
|
|||||||
return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
|
return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
//else
|
}
|
||||||
//{
|
|
||||||
// var id = GetLinuxUserId().Result ?? "1000";
|
public static bool IsPackagedInstall()
|
||||||
// if (int.TryParse(id, out var userId))
|
{
|
||||||
// {
|
try
|
||||||
// return userId == 0;
|
{
|
||||||
// }
|
if (IsWindows() || IsMacOS())
|
||||||
// else
|
{
|
||||||
// {
|
return false;
|
||||||
// return false;
|
}
|
||||||
// }
|
|
||||||
//}
|
var exePath = GetExePath();
|
||||||
|
var baseDir = string.IsNullOrEmpty(exePath) ? StartupPath() : Path.GetDirectoryName(exePath) ?? "";
|
||||||
|
var p = baseDir.Replace('\\', '/');
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(p))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.StartsWith("/opt/v2rayN", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.StartsWith("/usr/lib/v2rayN", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.StartsWith("/usr/share/v2rayN", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string?> GetLinuxUserId()
|
private static async Task<string?> GetLinuxUserId()
|
||||||
@@ -839,14 +1343,56 @@ public class Utils
|
|||||||
public static async Task<string?> SetLinuxChmod(string? fileName)
|
public static async Task<string?> SetLinuxChmod(string? fileName)
|
||||||
{
|
{
|
||||||
if (fileName.IsNullOrEmpty())
|
if (fileName.IsNullOrEmpty())
|
||||||
|
{
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
if (SetUnixFileMode(fileName))
|
||||||
|
{
|
||||||
|
Logging.SaveLog($"Successfully set the file execution permission, {fileName}");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
if (fileName.Contains(' '))
|
if (fileName.Contains(' '))
|
||||||
|
{
|
||||||
fileName = fileName.AppendQuotes();
|
fileName = fileName.AppendQuotes();
|
||||||
//File.SetUnixFileMode(fileName, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
}
|
||||||
var arg = new List<string>() { "-c", $"chmod +x {fileName}" };
|
var arg = new List<string>() { "-c", $"chmod +x {fileName}" };
|
||||||
return await GetCliWrapOutput(Global.LinuxBash, arg);
|
return await GetCliWrapOutput(Global.LinuxBash, arg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool SetUnixFileMode(string? fileName)
|
||||||
|
{
|
||||||
|
if (IsWindows())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SetUnixFileModeInternal(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnsupportedOSPlatform("windows")]
|
||||||
|
private static bool SetUnixFileModeInternal(string? fileName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (fileName.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(fileName))
|
||||||
|
{
|
||||||
|
var currentMode = File.GetUnixFileMode(fileName);
|
||||||
|
File.SetUnixFileMode(fileName, currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logging.SaveLog("SetUnixFileMode", ex);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<string?> GetLinuxFontFamily(string lang)
|
public static async Task<string?> GetLinuxFontFamily(string lang)
|
||||||
{
|
{
|
||||||
// var arg = new List<string>() { "-c", $"fc-list :lang={lang} family" };
|
// var arg = new List<string>() { "-c", $"fc-list :lang={lang} family" };
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace ServiceLib.Common;
|
namespace ServiceLib.Common;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
internal static class WindowsUtils
|
internal static class WindowsUtils
|
||||||
{
|
{
|
||||||
private static readonly string _tag = "WindowsUtils";
|
private static readonly string _tag = "WindowsUtils";
|
||||||
@@ -55,19 +54,23 @@ internal static class WindowsUtils
|
|||||||
|
|
||||||
public static async Task RemoveTunDevice()
|
public static async Task RemoveTunDevice()
|
||||||
{
|
{
|
||||||
try
|
var tunNameList = new List<string> { "wintunsingbox_tun", "xray_tun" };
|
||||||
|
foreach (var tunName in tunNameList)
|
||||||
{
|
{
|
||||||
var sum = MD5.HashData(Encoding.UTF8.GetBytes("wintunsingbox_tun"));
|
try
|
||||||
var guid = new Guid(sum);
|
{
|
||||||
var pnpUtilPath = @"C:\Windows\System32\pnputil.exe";
|
var sum = MD5.HashData(Encoding.UTF8.GetBytes(tunName));
|
||||||
var arg = $$""" /remove-device "SWD\Wintun\{{{guid}}}" """;
|
var guid = new Guid(sum);
|
||||||
|
var pnpUtilPath = @"C:\Windows\System32\pnputil.exe";
|
||||||
|
var arg = $$""" /remove-device "SWD\Wintun\{{{guid}}}" """;
|
||||||
|
|
||||||
// Try to remove the device
|
// Try to remove the device
|
||||||
_ = await Utils.GetCliWrapOutput(pnpUtilPath, arg);
|
_ = await Utils.GetCliWrapOutput(pnpUtilPath, arg);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logging.SaveLog(_tag, ex);
|
Logging.SaveLog(_tag, ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,10 @@ public enum EConfigType
|
|||||||
Hysteria2 = 7,
|
Hysteria2 = 7,
|
||||||
TUIC = 8,
|
TUIC = 8,
|
||||||
WireGuard = 9,
|
WireGuard = 9,
|
||||||
HTTP = 10
|
HTTP = 10,
|
||||||
|
Anytls = 11,
|
||||||
|
Naive = 12,
|
||||||
|
Outbound = 13,
|
||||||
|
PolicyGroup = 101,
|
||||||
|
ProxyChain = 102,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ public enum ECoreType
|
|||||||
hysteria2 = 26,
|
hysteria2 = 26,
|
||||||
brook = 27,
|
brook = 27,
|
||||||
overtls = 28,
|
overtls = 28,
|
||||||
|
shadowquic = 29,
|
||||||
|
mieru = 30,
|
||||||
v2rayN = 99
|
v2rayN = 99
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace ServiceLib.Enums;
|
|
||||||
|
|
||||||
public enum EMsgCommand
|
|
||||||
{
|
|
||||||
ClearMsg,
|
|
||||||
SendMsgView,
|
|
||||||
SendSnackMsg,
|
|
||||||
RefreshProfiles,
|
|
||||||
AppExit
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,9 @@ namespace ServiceLib.Enums;
|
|||||||
|
|
||||||
public enum EMultipleLoad
|
public enum EMultipleLoad
|
||||||
{
|
{
|
||||||
|
LeastPing,
|
||||||
|
Fallback,
|
||||||
Random,
|
Random,
|
||||||
RoundRobin,
|
RoundRobin,
|
||||||
LeastPing,
|
|
||||||
LeastLoad
|
LeastLoad
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ public enum ERuleMode
|
|||||||
Rule = 0,
|
Rule = 0,
|
||||||
Global = 1,
|
Global = 1,
|
||||||
Direct = 2,
|
Direct = 2,
|
||||||
Unchanged = 3
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ServiceLib.Enums;
|
||||||
|
|
||||||
|
public enum ERuleType
|
||||||
|
{
|
||||||
|
ALL = 0,
|
||||||
|
Routing = 1,
|
||||||
|
DNS = 2,
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ public enum EServerColName
|
|||||||
SubRemarks,
|
SubRemarks,
|
||||||
DelayVal,
|
DelayVal,
|
||||||
SpeedVal,
|
SpeedVal,
|
||||||
|
IpInfo,
|
||||||
|
|
||||||
TodayDown,
|
TodayDown,
|
||||||
TodayUp,
|
TodayUp,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public enum ESpeedActionType
|
|||||||
{
|
{
|
||||||
Tcping,
|
Tcping,
|
||||||
Realping,
|
Realping,
|
||||||
|
UdpTest,
|
||||||
Speedtest,
|
Speedtest,
|
||||||
Mixedtest
|
Mixedtest,
|
||||||
|
FastRealping
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ServiceLib.Enums;
|
|||||||
|
|
||||||
public enum ETransport
|
public enum ETransport
|
||||||
{
|
{
|
||||||
tcp,
|
raw,
|
||||||
kcp,
|
kcp,
|
||||||
ws,
|
ws,
|
||||||
httpupgrade,
|
httpupgrade,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
namespace ServiceLib.Enums;
|
|
||||||
|
|
||||||
public enum EViewAction
|
|
||||||
{
|
|
||||||
CloseWindow,
|
|
||||||
ShowYesNo,
|
|
||||||
SaveFileDialog,
|
|
||||||
AddBatchRoutingRulesYesNo,
|
|
||||||
AdjustMainLvColWidth,
|
|
||||||
SetClipboardData,
|
|
||||||
AddServerViaClipboard,
|
|
||||||
ImportRulesFromClipboard,
|
|
||||||
ProfilesFocus,
|
|
||||||
ShareSub,
|
|
||||||
ShareServer,
|
|
||||||
ShowHideWindow,
|
|
||||||
ScanScreenTask,
|
|
||||||
ScanImageTask,
|
|
||||||
Shutdown,
|
|
||||||
BrowseServer,
|
|
||||||
ImportRulesFromFile,
|
|
||||||
InitSettingFont,
|
|
||||||
PasswordInput,
|
|
||||||
SubEditWindow,
|
|
||||||
RoutingRuleSettingWindow,
|
|
||||||
RoutingRuleDetailsWindow,
|
|
||||||
AddServerWindow,
|
|
||||||
AddServer2Window,
|
|
||||||
DNSSettingWindow,
|
|
||||||
RoutingSettingWindow,
|
|
||||||
OptionSettingWindow,
|
|
||||||
GlobalHotkeySettingWindow,
|
|
||||||
SubSettingWindow,
|
|
||||||
DispatcherSpeedTest,
|
|
||||||
DispatcherRefreshConnections,
|
|
||||||
DispatcherRefreshProxyGroups,
|
|
||||||
DispatcherProxiesDelayTest,
|
|
||||||
DispatcherStatistics,
|
|
||||||
DispatcherServerAvailability,
|
|
||||||
DispatcherReload,
|
|
||||||
DispatcherRefreshServersBiz,
|
|
||||||
DispatcherRefreshIcon,
|
|
||||||
DispatcherCheckUpdate,
|
|
||||||
DispatcherCheckUpdateFinished,
|
|
||||||
DispatcherShowMsg,
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ServiceLib.Events;
|
||||||
|
|
||||||
|
public static class AppEvents
|
||||||
|
{
|
||||||
|
public static readonly EventChannel<RxVoid> AddServerViaClipboardRequested = new();
|
||||||
|
public static readonly EventChannel<bool> HasUpdateNotified = new();
|
||||||
|
|
||||||
|
public static readonly EventChannel<ServerSpeedItem> DispatcherStatisticsRequested = new();
|
||||||
|
|
||||||
|
public static readonly EventChannel<string> SendSnackMsgRequested = new();
|
||||||
|
public static readonly EventChannel<string> SendMsgViewRequested = new();
|
||||||
|
|
||||||
|
public static readonly EventChannel<RxVoid> AppExitRequested = new();
|
||||||
|
public static readonly EventChannel<bool> ShutdownRequested = new();
|
||||||
|
|
||||||
|
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ServiceLib.Events;
|
||||||
|
|
||||||
|
public sealed class EventChannel<T>
|
||||||
|
{
|
||||||
|
private readonly Signal<T> _signal = new();
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private readonly IObservable<T> _observable;
|
||||||
|
|
||||||
|
public EventChannel()
|
||||||
|
{
|
||||||
|
_observable = _signal.Synchronize(_gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IObservable<T> AsObservable()
|
||||||
|
{
|
||||||
|
return _observable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Publish(T value)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_signal.OnNext(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Publish()
|
||||||
|
{
|
||||||
|
if (typeof(T) != typeof(RxVoid))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Publish() without value is only valid for EventChannel<RxVoid>.");
|
||||||
|
}
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_signal.OnNext((T)(object)RxVoid.Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
|
||||||
<ReactiveUI />
|
|
||||||
</Weavers>
|
|
||||||
+504
-243
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,44 @@
|
|||||||
global using ServiceLib.Base;
|
global using System.Collections.Concurrent;
|
||||||
|
global using System.Collections.ObjectModel;
|
||||||
|
global using System.Collections.Specialized;
|
||||||
|
global using System.ComponentModel;
|
||||||
|
global using System.Diagnostics;
|
||||||
|
global using System.Net;
|
||||||
|
global using System.Net.NetworkInformation;
|
||||||
|
global using System.Net.Sockets;
|
||||||
|
global using ReactiveUI.Primitives;
|
||||||
|
global using ReactiveUI.Primitives.Concurrency;
|
||||||
|
global using ReactiveUI.Primitives.Extensions;
|
||||||
|
global using ReactiveUI.Primitives.Disposables;
|
||||||
|
global using ReactiveUI.Primitives.Signals;
|
||||||
|
global using System.Reflection;
|
||||||
|
global using System.Runtime.InteropServices;
|
||||||
|
global using System.Runtime.Versioning;
|
||||||
|
global using System.Security.Cryptography;
|
||||||
|
global using System.Text;
|
||||||
|
global using System.Text.Encodings.Web;
|
||||||
|
global using System.Text.Json;
|
||||||
|
global using System.Text.Json.Nodes;
|
||||||
|
global using System.Text.Json.Serialization;
|
||||||
|
global using System.Text.RegularExpressions;
|
||||||
|
global using ReactiveUI;
|
||||||
|
global using ReactiveUI.SourceGenerators;
|
||||||
|
global using ServiceLib.Base;
|
||||||
global using ServiceLib.Common;
|
global using ServiceLib.Common;
|
||||||
global using ServiceLib.Enums;
|
global using ServiceLib.Enums;
|
||||||
|
global using ServiceLib.Events;
|
||||||
global using ServiceLib.Handler;
|
global using ServiceLib.Handler;
|
||||||
|
global using ServiceLib.Handler.Builder;
|
||||||
global using ServiceLib.Handler.Fmt;
|
global using ServiceLib.Handler.Fmt;
|
||||||
global using ServiceLib.Services;
|
|
||||||
global using ServiceLib.Services.Statistics;
|
|
||||||
global using ServiceLib.Services.CoreConfig;
|
|
||||||
global using ServiceLib.Models;
|
|
||||||
global using ServiceLib.Resx;
|
|
||||||
global using ServiceLib.Handler.SysProxy;
|
global using ServiceLib.Handler.SysProxy;
|
||||||
|
global using ServiceLib.Helper;
|
||||||
|
global using ServiceLib.Manager;
|
||||||
|
global using ServiceLib.Models.Configs;
|
||||||
|
global using ServiceLib.Models.CoreConfigs;
|
||||||
|
global using ServiceLib.Models.Dto;
|
||||||
|
global using ServiceLib.Models.Entities;
|
||||||
|
global using ServiceLib.Resx;
|
||||||
|
global using ServiceLib.Services;
|
||||||
|
global using ServiceLib.Services.CoreConfig;
|
||||||
|
global using ServiceLib.Services.Statistics;
|
||||||
|
global using SQLite;
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
namespace ServiceLib.Handler;
|
|
||||||
|
|
||||||
public sealed class AppHandler
|
|
||||||
{
|
|
||||||
#region Property
|
|
||||||
|
|
||||||
private static readonly Lazy<AppHandler> _instance = new(() => new());
|
|
||||||
private Config _config;
|
|
||||||
private int? _statePort;
|
|
||||||
private int? _statePort2;
|
|
||||||
private Job? _processJob;
|
|
||||||
public static AppHandler Instance => _instance.Value;
|
|
||||||
public Config Config => _config;
|
|
||||||
|
|
||||||
public int StatePort
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
_statePort ??= Utils.GetFreePort(GetLocalPort(EInboundProtocol.api));
|
|
||||||
return _statePort.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int StatePort2
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
_statePort2 ??= Utils.GetFreePort(GetLocalPort(EInboundProtocol.api2));
|
|
||||||
return _statePort2.Value + (_config.TunModeItem.EnableTun ? 1 : 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string LinuxSudoPwd { get; set; }
|
|
||||||
|
|
||||||
#endregion Property
|
|
||||||
|
|
||||||
#region Init
|
|
||||||
|
|
||||||
public bool InitApp()
|
|
||||||
{
|
|
||||||
if (Utils.HasWritePermission() == false)
|
|
||||||
{
|
|
||||||
Environment.SetEnvironmentVariable(Global.LocalAppData, "1", EnvironmentVariableTarget.Process);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logging.Setup();
|
|
||||||
var config = ConfigHandler.LoadConfig();
|
|
||||||
if (config == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
_config = config;
|
|
||||||
Thread.CurrentThread.CurrentUICulture = new(_config.UiItem.CurrentLanguage);
|
|
||||||
|
|
||||||
//Under Win10
|
|
||||||
if (Utils.IsWindows() && Environment.OSVersion.Version.Major < 10)
|
|
||||||
{
|
|
||||||
Environment.SetEnvironmentVariable("DOTNET_EnableWriteXorExecute", "0", EnvironmentVariableTarget.User);
|
|
||||||
}
|
|
||||||
|
|
||||||
SQLiteHelper.Instance.CreateTable<SubItem>();
|
|
||||||
SQLiteHelper.Instance.CreateTable<ProfileItem>();
|
|
||||||
SQLiteHelper.Instance.CreateTable<ServerStatItem>();
|
|
||||||
SQLiteHelper.Instance.CreateTable<RoutingItem>();
|
|
||||||
SQLiteHelper.Instance.CreateTable<ProfileExItem>();
|
|
||||||
SQLiteHelper.Instance.CreateTable<DNSItem>();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool InitComponents()
|
|
||||||
{
|
|
||||||
Logging.SaveLog($"v2rayN start up | {Utils.GetRuntimeInfo()}");
|
|
||||||
Logging.LoggingEnabled(_config.GuiItem.EnableLog);
|
|
||||||
|
|
||||||
//First determine the port value
|
|
||||||
_ = StatePort;
|
|
||||||
_ = StatePort2;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Reset()
|
|
||||||
{
|
|
||||||
_statePort = null;
|
|
||||||
_statePort2 = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Init
|
|
||||||
|
|
||||||
#region Config
|
|
||||||
|
|
||||||
public int GetLocalPort(EInboundProtocol protocol)
|
|
||||||
{
|
|
||||||
var localPort = _config.Inbound.FirstOrDefault(t => t.Protocol == nameof(EInboundProtocol.socks))?.LocalPort ?? 10808;
|
|
||||||
return localPort + (int)protocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddProcess(IntPtr processHandle)
|
|
||||||
{
|
|
||||||
if (Utils.IsWindows())
|
|
||||||
{
|
|
||||||
_processJob ??= new();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_processJob?.AddProcess(processHandle);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Config
|
|
||||||
|
|
||||||
#region SqliteHelper
|
|
||||||
|
|
||||||
public async Task<List<SubItem>?> SubItems()
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<SubItem>().OrderBy(t => t.Sort).ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SubItem?> GetSubItem(string? subid)
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<SubItem>().FirstOrDefaultAsync(t => t.Id == subid);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<ProfileItem>?> ProfileItems(string subid)
|
|
||||||
{
|
|
||||||
if (subid.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().ToListAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().Where(t => t.Subid == subid).ToListAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<string>?> ProfileItemIndexes(string subid)
|
|
||||||
{
|
|
||||||
return (await ProfileItems(subid))?.Select(t => t.IndexId)?.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<ProfileItemModel>?> ProfileItems(string subid, string filter)
|
|
||||||
{
|
|
||||||
var sql = @$"select a.*
|
|
||||||
,b.remarks subRemarks
|
|
||||||
from ProfileItem a
|
|
||||||
left join SubItem b on a.subid = b.id
|
|
||||||
where 1=1 ";
|
|
||||||
if (subid.IsNotEmpty())
|
|
||||||
{
|
|
||||||
sql += $" and a.subid = '{subid}'";
|
|
||||||
}
|
|
||||||
if (filter.IsNotEmpty())
|
|
||||||
{
|
|
||||||
if (filter.Contains('\''))
|
|
||||||
{
|
|
||||||
filter = filter.Replace("'", "");
|
|
||||||
}
|
|
||||||
sql += string.Format(" and (a.remarks like '%{0}%' or a.address like '%{0}%') ", filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await SQLiteHelper.Instance.QueryAsync<ProfileItemModel>(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProfileItem?> GetProfileItem(string indexId)
|
|
||||||
{
|
|
||||||
if (indexId.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().FirstOrDefaultAsync(it => it.IndexId == indexId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProfileItem?> GetProfileItemViaRemarks(string? remarks)
|
|
||||||
{
|
|
||||||
if (remarks.IsNullOrEmpty())
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().FirstOrDefaultAsync(it => it.Remarks == remarks);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<RoutingItem>?> RoutingItems()
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<RoutingItem>().OrderBy(t => t.Sort).ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RoutingItem?> GetRoutingItem(string id)
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<RoutingItem>().FirstOrDefaultAsync(it => it.Id == id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<DNSItem>?> DNSItems()
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<DNSItem>().ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DNSItem?> GetDNSItem(ECoreType eCoreType)
|
|
||||||
{
|
|
||||||
return await SQLiteHelper.Instance.TableAsync<DNSItem>().FirstOrDefaultAsync(it => it.CoreType == eCoreType);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion SqliteHelper
|
|
||||||
|
|
||||||
#region Core Type
|
|
||||||
|
|
||||||
public List<string> GetShadowsocksSecurities(ProfileItem profileItem)
|
|
||||||
{
|
|
||||||
var coreType = GetCoreType(profileItem, EConfigType.Shadowsocks);
|
|
||||||
switch (coreType)
|
|
||||||
{
|
|
||||||
case ECoreType.v2fly:
|
|
||||||
return Global.SsSecurities;
|
|
||||||
|
|
||||||
case ECoreType.Xray:
|
|
||||||
return Global.SsSecuritiesInXray;
|
|
||||||
|
|
||||||
case ECoreType.sing_box:
|
|
||||||
return Global.SsSecuritiesInSingbox;
|
|
||||||
}
|
|
||||||
return Global.SsSecuritiesInSingbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ECoreType GetCoreType(ProfileItem profileItem, EConfigType eConfigType)
|
|
||||||
{
|
|
||||||
if (profileItem?.CoreType != null)
|
|
||||||
{
|
|
||||||
return (ECoreType)profileItem.CoreType;
|
|
||||||
}
|
|
||||||
|
|
||||||
var item = _config.CoreTypeItem?.FirstOrDefault(it => it.ConfigType == eConfigType);
|
|
||||||
return item?.CoreType ?? ECoreType.Xray;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Core Type
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace ServiceLib.Handler;
|
namespace ServiceLib.Handler;
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ public static class AutoStartupHandler
|
|||||||
await SetTaskLinux();
|
await SetTaskLinux();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (Utils.IsOSX())
|
else if (Utils.IsMacOS())
|
||||||
{
|
{
|
||||||
await ClearTaskOSX();
|
await ClearTaskOSX();
|
||||||
|
|
||||||
@@ -42,6 +41,7 @@ public static class AutoStartupHandler
|
|||||||
|
|
||||||
#region Windows
|
#region Windows
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
private static async Task ClearTaskWindows()
|
private static async Task ClearTaskWindows()
|
||||||
{
|
{
|
||||||
var autoRunName = GetAutoRunNameWindows();
|
var autoRunName = GetAutoRunNameWindows();
|
||||||
@@ -54,6 +54,7 @@ public static class AutoStartupHandler
|
|||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
private static async Task SetTaskWindows()
|
private static async Task SetTaskWindows()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -83,6 +84,7 @@ public static class AutoStartupHandler
|
|||||||
/// <param name="fileName"></param>
|
/// <param name="fileName"></param>
|
||||||
/// <param name="description"></param>
|
/// <param name="description"></param>
|
||||||
/// <exception cref="ArgumentNullException"></exception>
|
/// <exception cref="ArgumentNullException"></exception>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
public static void AutoStartTaskService(string taskName, string fileName, string description)
|
public static void AutoStartTaskService(string taskName, string fileName, string description)
|
||||||
{
|
{
|
||||||
if (taskName.IsNullOrEmpty())
|
if (taskName.IsNullOrEmpty())
|
||||||
@@ -109,7 +111,8 @@ public static class AutoStartupHandler
|
|||||||
task.Settings.RunOnlyIfIdle = false;
|
task.Settings.RunOnlyIfIdle = false;
|
||||||
task.Settings.IdleSettings.StopOnIdleEnd = false;
|
task.Settings.IdleSettings.StopOnIdleEnd = false;
|
||||||
task.Settings.ExecutionTimeLimit = TimeSpan.Zero;
|
task.Settings.ExecutionTimeLimit = TimeSpan.Zero;
|
||||||
task.Triggers.Add(new Microsoft.Win32.TaskScheduler.LogonTrigger { UserId = logonUser, Delay = TimeSpan.FromSeconds(30) });
|
task.Settings.Priority = ProcessPriorityClass.Normal;
|
||||||
|
task.Triggers.Add(new Microsoft.Win32.TaskScheduler.LogonTrigger { UserId = logonUser });
|
||||||
task.Principal.RunLevel = Microsoft.Win32.TaskScheduler.TaskRunLevel.Highest;
|
task.Principal.RunLevel = Microsoft.Win32.TaskScheduler.TaskRunLevel.Highest;
|
||||||
task.Actions.Add(new Microsoft.Win32.TaskScheduler.ExecAction(fileName.AppendQuotes(), null, Path.GetDirectoryName(fileName)));
|
task.Actions.Add(new Microsoft.Win32.TaskScheduler.ExecAction(fileName.AppendQuotes(), null, Path.GetDirectoryName(fileName)));
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ public static class AutoStartupHandler
|
|||||||
|
|
||||||
#region Linux
|
#region Linux
|
||||||
|
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
private static async Task ClearTaskLinux()
|
private static async Task ClearTaskLinux()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -138,6 +142,7 @@ public static class AutoStartupHandler
|
|||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
private static async Task SetTaskLinux()
|
private static async Task SetTaskLinux()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -158,6 +163,7 @@ public static class AutoStartupHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
private static string GetHomePathLinux()
|
private static string GetHomePathLinux()
|
||||||
{
|
{
|
||||||
var homePath = Path.Combine(Utils.GetHomePath(), ".config", "autostart", $"{Global.AppName}.desktop");
|
var homePath = Path.Combine(Utils.GetHomePath(), ".config", "autostart", $"{Global.AppName}.desktop");
|
||||||
@@ -169,6 +175,7 @@ public static class AutoStartupHandler
|
|||||||
|
|
||||||
#region macOS
|
#region macOS
|
||||||
|
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
private static async Task ClearTaskOSX()
|
private static async Task ClearTaskOSX()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -188,6 +195,7 @@ public static class AutoStartupHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
private static async Task SetTaskOSX()
|
private static async Task SetTaskOSX()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -205,6 +213,7 @@ public static class AutoStartupHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
private static string GetLaunchAgentPathMacOS()
|
private static string GetLaunchAgentPathMacOS()
|
||||||
{
|
{
|
||||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
@@ -213,6 +222,7 @@ public static class AutoStartupHandler
|
|||||||
return launchAgentPath;
|
return launchAgentPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
private static string GenerateLaunchAgentPlist()
|
private static string GenerateLaunchAgentPlist()
|
||||||
{
|
{
|
||||||
var exePath = Utils.GetExePath();
|
var exePath = Utils.GetExePath();
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
namespace ServiceLib.Handler.Builder;
|
||||||
|
|
||||||
|
public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult)
|
||||||
|
{
|
||||||
|
public bool Success => ValidatorResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the results of a full context build, including the main context and an optional
|
||||||
|
/// pre-socks context (e.g. for TUN protection or pre-socks chaining).
|
||||||
|
/// </summary>
|
||||||
|
public record CoreConfigContextBuilderAllResult(
|
||||||
|
CoreConfigContextBuilderResult MainResult,
|
||||||
|
CoreConfigContextBuilderResult? PreSocksResult)
|
||||||
|
{
|
||||||
|
/// <summary>True only when both the main result and (if present) the pre-socks result succeeded.</summary>
|
||||||
|
public bool Success => MainResult.Success && (PreSocksResult?.Success ?? true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges all errors and warnings from the main result and the optional pre-socks result
|
||||||
|
/// into a single <see cref="NodeValidatorResult" /> for unified notification.
|
||||||
|
/// </summary>
|
||||||
|
public NodeValidatorResult CombinedValidatorResult => new(
|
||||||
|
[.. MainResult.ValidatorResult.Errors, .. PreSocksResult?.ValidatorResult.Errors ?? []],
|
||||||
|
[.. MainResult.ValidatorResult.Warnings, .. PreSocksResult?.ValidatorResult.Warnings ?? []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CoreConfigContextBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="CoreConfigContext" /> for the given node, resolves its proxy map,
|
||||||
|
/// and processes outbound nodes referenced by routing rules.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<CoreConfigContextBuilderResult> Build(Config config, ProfileItem node)
|
||||||
|
{
|
||||||
|
var runCoreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
|
||||||
|
var coreType = runCoreType == ECoreType.sing_box ? ECoreType.sing_box : ECoreType.Xray;
|
||||||
|
var context = new CoreConfigContext
|
||||||
|
{
|
||||||
|
Node = node,
|
||||||
|
RunCoreType = runCoreType,
|
||||||
|
AllProxiesMap = [],
|
||||||
|
AppConfig = config,
|
||||||
|
FullConfigTemplate = await AppManager.Instance.GetFullConfigTemplateItem(coreType),
|
||||||
|
IsTunEnabled = config.TunModeItem.EnableTun,
|
||||||
|
SimpleDnsItem = config.SimpleDNSItem,
|
||||||
|
ProtectDomainList = [],
|
||||||
|
RawDnsItem = await AppManager.Instance.GetDNSItem(coreType),
|
||||||
|
RoutingItem = await ConfigHandler.GetDefaultRouting(config),
|
||||||
|
IsWindows = Utils.IsWindows(),
|
||||||
|
IsMacOS = Utils.IsMacOS(),
|
||||||
|
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
|
||||||
|
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
|
||||||
|
};
|
||||||
|
var validatorResult = NodeValidatorResult.Empty();
|
||||||
|
var (actNode, nodeValidatorResult) = await ResolveNodeAsync(context, node);
|
||||||
|
if (!nodeValidatorResult.Success)
|
||||||
|
{
|
||||||
|
return new CoreConfigContextBuilderResult(context, nodeValidatorResult);
|
||||||
|
}
|
||||||
|
context = context with { Node = actNode };
|
||||||
|
validatorResult.Warnings.AddRange(nodeValidatorResult.Warnings);
|
||||||
|
if (!(context.RoutingItem?.RuleSet.IsNullOrEmpty() ?? true))
|
||||||
|
{
|
||||||
|
var rules = JsonUtils.Deserialize<List<RulesItem>>(context.RoutingItem?.RuleSet) ?? [];
|
||||||
|
foreach (var ruleItem in rules.Where(ruleItem =>
|
||||||
|
ruleItem.Enabled && !Global.OutboundTags.Contains(ruleItem.OutboundTag)))
|
||||||
|
{
|
||||||
|
if (ruleItem.OutboundTag.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgRoutingRuleEmptyOutboundTag, ruleItem.Remarks));
|
||||||
|
ruleItem.OutboundTag = Global.ProxyTag;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var ruleOutboundNode = await AppManager.Instance.GetProfileItemViaRemarks(ruleItem.OutboundTag);
|
||||||
|
if (ruleOutboundNode == null)
|
||||||
|
{
|
||||||
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgRoutingRuleOutboundNodeNotFound,
|
||||||
|
ruleItem.Remarks, ruleItem.OutboundTag));
|
||||||
|
ruleItem.OutboundTag = Global.ProxyTag;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (actRuleNode, ruleNodeValidatorResult) = await ResolveNodeAsync(context, ruleOutboundNode, false);
|
||||||
|
validatorResult.Warnings.AddRange(ruleNodeValidatorResult.Warnings.Select(w =>
|
||||||
|
string.Format(ResUI.MsgRoutingRuleOutboundNodeWarning, ruleItem.Remarks, ruleItem.OutboundTag, w)));
|
||||||
|
if (!ruleNodeValidatorResult.Success)
|
||||||
|
{
|
||||||
|
validatorResult.Warnings.AddRange(ruleNodeValidatorResult.Errors.Select(e =>
|
||||||
|
string.Format(ResUI.MsgRoutingRuleOutboundNodeError, ruleItem.Remarks, ruleItem.OutboundTag,
|
||||||
|
e)));
|
||||||
|
ruleItem.OutboundTag = Global.ProxyTag;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.AllProxiesMap[$"remark:{ruleItem.OutboundTag}"] = actRuleNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.IsTunEnabled && context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var appConfig = JsonUtils.DeepCopy(config);
|
||||||
|
var routeExcludeAddressList = new List<string>();
|
||||||
|
foreach (var addr in context.AppConfig.TunModeItem.RouteExcludeAddress)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IPNetwork2.Parse(addr);
|
||||||
|
routeExcludeAddressList.Add(addr);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgTunRouteExcludeInvalidAddress, addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appConfig.TunModeItem.RouteExcludeAddress = routeExcludeAddressList;
|
||||||
|
context = context with { AppConfig = appConfig };
|
||||||
|
}
|
||||||
|
if (!context.AppConfig.CoreBasicItem.SendThrough.IsNullOrEmpty()
|
||||||
|
&& !Utils.IsLocalIP(context.AppConfig.CoreBasicItem.SendThrough))
|
||||||
|
{
|
||||||
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsSendThrough));
|
||||||
|
var appConfig = JsonUtils.DeepCopy(config);
|
||||||
|
appConfig.CoreBasicItem.SendThrough = string.Empty;
|
||||||
|
context = context with { AppConfig = appConfig };
|
||||||
|
}
|
||||||
|
if (!context.AppConfig.CoreBasicItem.BindInterface.IsNullOrEmpty()
|
||||||
|
&& (!Utils.ContainsInterfaceName(context.AppConfig.CoreBasicItem.BindInterface)
|
||||||
|
|| !(context.IsTunEnabled || context.IsWindows)))
|
||||||
|
{
|
||||||
|
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsBindInterface));
|
||||||
|
var appConfig = JsonUtils.DeepCopy(config);
|
||||||
|
appConfig.CoreBasicItem.BindInterface = string.Empty;
|
||||||
|
context = context with { AppConfig = appConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CoreConfigContextBuilderResult(context, validatorResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the main <see cref="CoreConfigContext" /> for <paramref name="node" /> and, when
|
||||||
|
/// the main build succeeds, also builds the optional pre-socks context required for TUN
|
||||||
|
/// protection or pre-socks proxy chaining.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<CoreConfigContextBuilderAllResult> BuildAll(Config config, ProfileItem node)
|
||||||
|
{
|
||||||
|
var mainResult = await Build(config, node);
|
||||||
|
if (!mainResult.Success)
|
||||||
|
{
|
||||||
|
return new CoreConfigContextBuilderAllResult(mainResult, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var preResult = await BuildPreSocksIfNeeded(mainResult.Context);
|
||||||
|
if (preResult is null)
|
||||||
|
{
|
||||||
|
return new CoreConfigContextBuilderAllResult(mainResult, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedMainResult = mainResult with
|
||||||
|
{
|
||||||
|
Context = mainResult.Context with
|
||||||
|
{
|
||||||
|
IsTunEnabled = false,
|
||||||
|
// main core doesn't handle tun directly when pre-socks is used
|
||||||
|
ProtectDomainList = [.. mainResult.Context.ProtectDomainList, .. preResult.Context.ProtectDomainList],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (mainResult.Context.IsTunEnabled
|
||||||
|
&& mainResult.Context.AppConfig.TunModeItem.StrictRoute)
|
||||||
|
{
|
||||||
|
var appConfig = JsonUtils.DeepCopy(mainResult.Context.AppConfig);
|
||||||
|
appConfig.CoreBasicItem.BindInterface = string.Empty;
|
||||||
|
appConfig.CoreBasicItem.SendThrough = string.Empty;
|
||||||
|
resolvedMainResult = resolvedMainResult with
|
||||||
|
{
|
||||||
|
Context = resolvedMainResult.Context with
|
||||||
|
{
|
||||||
|
AppConfig = appConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new CoreConfigContextBuilderAllResult(resolvedMainResult, preResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a pre-socks context is required for <paramref name="nodeContext" />
|
||||||
|
/// and, if so, builds and returns it. Returns <c>null</c> when no pre-socks core is needed.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<CoreConfigContextBuilderResult?> BuildPreSocksIfNeeded(CoreConfigContext nodeContext)
|
||||||
|
{
|
||||||
|
var config = nodeContext.AppConfig;
|
||||||
|
var node = nodeContext.Node;
|
||||||
|
var coreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
|
||||||
|
|
||||||
|
var preSocksItem = ConfigHandler.GetPreSocksItem(config, node, coreType);
|
||||||
|
if (preSocksItem != null)
|
||||||
|
{
|
||||||
|
var preSocksResult = await Build(nodeContext.AppConfig, preSocksItem);
|
||||||
|
|
||||||
|
var protectCoreTypeList = new HashSet<ECoreType>(nodeContext.ProtectCoreTypeList) { nodeContext.RunCoreType };
|
||||||
|
|
||||||
|
return preSocksResult with
|
||||||
|
{
|
||||||
|
Context = preSocksResult.Context with
|
||||||
|
{
|
||||||
|
ProtectDomainList =
|
||||||
|
[.. nodeContext.ProtectDomainList, .. preSocksResult.Context.ProtectDomainList],
|
||||||
|
ProtectCoreTypeList = protectCoreTypeList,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a node into the context, optionally wrapping it in a subscription-level proxy chain.
|
||||||
|
/// Returns the effective (possibly replaced) node and the validation result.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<(ProfileItem, NodeValidatorResult)> ResolveNodeAsync(CoreConfigContext context,
|
||||||
|
ProfileItem node,
|
||||||
|
bool includeSubChain = true)
|
||||||
|
{
|
||||||
|
if (node.IndexId.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
return (node, NodeValidatorResult.Empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeSubChain)
|
||||||
|
{
|
||||||
|
var (virtualChainNode, chainValidatorResult) = await BuildSubscriptionChainNodeAsync(node);
|
||||||
|
if (virtualChainNode != null)
|
||||||
|
{
|
||||||
|
context.AllProxiesMap[virtualChainNode.IndexId] = virtualChainNode;
|
||||||
|
var (resolvedNode, resolvedResult) = await ResolveNodeAsync(context, virtualChainNode, false);
|
||||||
|
resolvedResult.Warnings.InsertRange(0, chainValidatorResult.Warnings);
|
||||||
|
return (resolvedNode, resolvedResult);
|
||||||
|
}
|
||||||
|
// Chain not built but warnings may still exist (e.g. missing profiles)
|
||||||
|
if (chainValidatorResult.Warnings.Count > 0)
|
||||||
|
{
|
||||||
|
var fillResult = await RegisterNodeAsync(context, node);
|
||||||
|
fillResult.Warnings.InsertRange(0, chainValidatorResult.Warnings);
|
||||||
|
return (node, fillResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var registerResult = await RegisterNodeAsync(context, node);
|
||||||
|
return (node, registerResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If the node's subscription defines prev/next profiles, creates a virtual
|
||||||
|
/// <see cref="EConfigType.ProxyChain" /> node that wraps them together.
|
||||||
|
/// Returns <c>null</c> as the chain item when no chain is needed.
|
||||||
|
/// Any warnings (e.g. missing prev/next profile) are returned in the validator result.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(ProfileItem? ChainNode, NodeValidatorResult ValidatorResult)>
|
||||||
|
BuildSubscriptionChainNodeAsync(ProfileItem node)
|
||||||
|
{
|
||||||
|
var result = NodeValidatorResult.Empty();
|
||||||
|
|
||||||
|
if (node.Subid.IsNullOrEmpty() || node.ConfigType == EConfigType.Custom)
|
||||||
|
{
|
||||||
|
return (null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var subItem = await AppManager.Instance.GetSubItem(node.Subid);
|
||||||
|
if (subItem == null)
|
||||||
|
{
|
||||||
|
return (null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileItem? prevNode = null;
|
||||||
|
ProfileItem? nextNode = null;
|
||||||
|
|
||||||
|
if (!subItem.PrevProfile.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
prevNode = await AppManager.Instance.GetProfileItemViaRemarks(subItem.PrevProfile);
|
||||||
|
if (prevNode == null)
|
||||||
|
{
|
||||||
|
result.Warnings.Add(string.Format(ResUI.MsgSubscriptionPrevProfileNotFound, subItem.PrevProfile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!subItem.NextProfile.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
nextNode = await AppManager.Instance.GetProfileItemViaRemarks(subItem.NextProfile);
|
||||||
|
if (nextNode == null)
|
||||||
|
{
|
||||||
|
result.Warnings.Add(string.Format(ResUI.MsgSubscriptionNextProfileNotFound, subItem.NextProfile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevNode is null && nextNode is null)
|
||||||
|
{
|
||||||
|
return (null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new proxy chain node
|
||||||
|
var chainNode = new ProfileItem
|
||||||
|
{
|
||||||
|
IndexId = $"inner-{Utils.GetGuid(false)}",
|
||||||
|
ConfigType = EConfigType.ProxyChain,
|
||||||
|
CoreType = AppManager.Instance.GetCoreType(node, node.ConfigType),
|
||||||
|
Remarks = node.Remarks,
|
||||||
|
};
|
||||||
|
List<string?> childItems = [prevNode?.IndexId, node.IndexId, nextNode?.IndexId];
|
||||||
|
var chainExtraItem = chainNode.GetProtocolExtra() with
|
||||||
|
{
|
||||||
|
GroupType = chainNode.ConfigType.ToString(),
|
||||||
|
ChildItems = string.Join(",", childItems.Where(x => !x.IsNullOrEmpty())),
|
||||||
|
};
|
||||||
|
chainNode.SetProtocolExtra(chainExtraItem);
|
||||||
|
return (chainNode, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches registration to either <see cref="RegisterGroupNodeAsync" /> or
|
||||||
|
/// <see cref="RegisterSingleNodeAsync" /> based on the node's config type.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<NodeValidatorResult> RegisterNodeAsync(CoreConfigContext context, ProfileItem node)
|
||||||
|
{
|
||||||
|
if (node.ConfigType.IsGroupType())
|
||||||
|
{
|
||||||
|
return await RegisterGroupNodeAsync(context, node);
|
||||||
|
}
|
||||||
|
return await RegisterSingleNodeAsync(context, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a single (non-group) node and, on success, adds it to the proxy map
|
||||||
|
/// and records any domain addresses that should bypass the proxy.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<NodeValidatorResult> RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node)
|
||||||
|
{
|
||||||
|
if (node.ConfigType.IsGroupType())
|
||||||
|
{
|
||||||
|
return NodeValidatorResult.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeValidatorResult = NodeValidator.Validate(node, context.RunCoreType);
|
||||||
|
|
||||||
|
if (node.ConfigType == EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
var addressFileName = node.Address;
|
||||||
|
if (!File.Exists(addressFileName))
|
||||||
|
{
|
||||||
|
addressFileName = Utils.GetConfigPath(addressFileName);
|
||||||
|
}
|
||||||
|
if (!File.Exists(addressFileName))
|
||||||
|
{
|
||||||
|
nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName));
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileContent = await File.ReadAllTextAsync(addressFileName);
|
||||||
|
context.CustomOutboundContent[node.IndexId] = fileContent;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
nodeValidatorResult.Errors.Add(string.Format(ResUI.MsgCustomOutboundFileNotFound, node.Remarks, addressFileName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgs = new List<string>([.. nodeValidatorResult.Errors, .. nodeValidatorResult.Warnings]);
|
||||||
|
if (msgs.Count > 0)
|
||||||
|
{
|
||||||
|
Logging.SaveLog($"{node.Remarks}: {string.Join("; ", msgs)}");
|
||||||
|
}
|
||||||
|
if (!nodeValidatorResult.Success)
|
||||||
|
{
|
||||||
|
return nodeValidatorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.AllProxiesMap[node.IndexId] = node;
|
||||||
|
|
||||||
|
var address = node.Address;
|
||||||
|
if (Utils.IsDomain(address))
|
||||||
|
{
|
||||||
|
context.ProtectDomainList.Add(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ech query server name protect
|
||||||
|
if (!node.EchConfigList.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
var echQuerySni = node.Sni;
|
||||||
|
if (node.StreamSecurity == Global.StreamSecurity
|
||||||
|
&& node.EchConfigList.Contains("://"))
|
||||||
|
{
|
||||||
|
var idx = node.EchConfigList.IndexOf('+');
|
||||||
|
echQuerySni = idx > 0 ? node.EchConfigList[..idx] : node.Sni;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Utils.IsDomain(echQuerySni))
|
||||||
|
{
|
||||||
|
context.ProtectDomainList.Add(echQuerySni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// xhttp downloadSettings address protect
|
||||||
|
var xhttpExtra = node.GetTransportExtra().XhttpExtra;
|
||||||
|
if (!string.IsNullOrEmpty(xhttpExtra)
|
||||||
|
&& JsonUtils.ParseJson(xhttpExtra) is JsonObject extra
|
||||||
|
&& extra.TryGetPropertyValue("downloadSettings", out var dsNode)
|
||||||
|
&& dsNode is JsonObject downloadSettings
|
||||||
|
&& downloadSettings.TryGetPropertyValue("address", out var dAddrNode)
|
||||||
|
&& dAddrNode is JsonValue dAddrValue
|
||||||
|
&& dAddrValue.TryGetValue(out string? dAddr)
|
||||||
|
&& !string.IsNullOrEmpty(dAddr)
|
||||||
|
&& Utils.IsDomain(dAddr))
|
||||||
|
{
|
||||||
|
context.ProtectDomainList.Add(dAddr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeValidatorResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point for registering a group node. Initialises the visited/ancestor sets
|
||||||
|
/// and delegates to <see cref="TraverseGroupNodeAsync" />.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<NodeValidatorResult> RegisterGroupNodeAsync(CoreConfigContext context,
|
||||||
|
ProfileItem node)
|
||||||
|
{
|
||||||
|
if (!node.ConfigType.IsGroupType())
|
||||||
|
{
|
||||||
|
return NodeValidatorResult.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> ancestors = [node.IndexId];
|
||||||
|
HashSet<string> globalVisited = [node.IndexId];
|
||||||
|
return await TraverseGroupNodeAsync(context, node, globalVisited, ancestors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively walks the children of a group node, registering valid leaf nodes
|
||||||
|
/// and nested groups. Detects cycles via <paramref name="ancestorsGroup" /> and
|
||||||
|
/// deduplicates shared nodes via <paramref name="globalVisitedGroup" />.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<NodeValidatorResult> TraverseGroupNodeAsync(
|
||||||
|
CoreConfigContext context,
|
||||||
|
ProfileItem node,
|
||||||
|
HashSet<string> globalVisitedGroup,
|
||||||
|
HashSet<string> ancestorsGroup)
|
||||||
|
{
|
||||||
|
var (groupChildList, _) = await GroupProfileManager.GetChildProfileItems(node);
|
||||||
|
List<string> childIndexIdList = [];
|
||||||
|
var childNodeValidatorResult = NodeValidatorResult.Empty();
|
||||||
|
foreach (var childNode in groupChildList)
|
||||||
|
{
|
||||||
|
if (ancestorsGroup.Contains(childNode.IndexId))
|
||||||
|
{
|
||||||
|
childNodeValidatorResult.Errors.Add(
|
||||||
|
string.Format(ResUI.MsgGroupCycleDependency, node.Remarks, childNode.Remarks));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalVisitedGroup.Contains(childNode.IndexId))
|
||||||
|
{
|
||||||
|
childIndexIdList.Add(childNode.IndexId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!childNode.ConfigType.IsGroupType())
|
||||||
|
{
|
||||||
|
var childNodeResult = await RegisterSingleNodeAsync(context, childNode);
|
||||||
|
childNodeValidatorResult.Warnings.AddRange(childNodeResult.Warnings.Select(w =>
|
||||||
|
string.Format(ResUI.MsgGroupChildNodeWarning, node.Remarks, childNode.Remarks, w)));
|
||||||
|
childNodeValidatorResult.Errors.AddRange(childNodeResult.Errors.Select(e =>
|
||||||
|
string.Format(ResUI.MsgGroupChildNodeError, node.Remarks, childNode.Remarks, e)));
|
||||||
|
if (!childNodeResult.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalVisitedGroup.Add(childNode.IndexId);
|
||||||
|
childIndexIdList.Add(childNode.IndexId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAncestorsGroup = new HashSet<string>(ancestorsGroup) { childNode.IndexId };
|
||||||
|
var childGroupResult =
|
||||||
|
await TraverseGroupNodeAsync(context, childNode, globalVisitedGroup, newAncestorsGroup);
|
||||||
|
childNodeValidatorResult.Warnings.AddRange(childGroupResult.Warnings.Select(w =>
|
||||||
|
string.Format(ResUI.MsgGroupChildGroupNodeWarning, node.Remarks, childNode.Remarks, w)));
|
||||||
|
childNodeValidatorResult.Errors.AddRange(childGroupResult.Errors.Select(e =>
|
||||||
|
string.Format(ResUI.MsgGroupChildGroupNodeError, node.Remarks, childNode.Remarks, e)));
|
||||||
|
if (!childGroupResult.Success)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalVisitedGroup.Add(childNode.IndexId);
|
||||||
|
childIndexIdList.Add(childNode.IndexId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childIndexIdList.Count == 0)
|
||||||
|
{
|
||||||
|
childNodeValidatorResult.Errors.Add(string.Format(ResUI.MsgGroupNoValidChildNode, node.Remarks));
|
||||||
|
return childNodeValidatorResult;
|
||||||
|
}
|
||||||
|
childNodeValidatorResult.Warnings.AddRange(childNodeValidatorResult.Errors);
|
||||||
|
childNodeValidatorResult.Errors.Clear();
|
||||||
|
|
||||||
|
node.SetProtocolExtra(node.GetProtocolExtra() with { ChildItems = Utils.List2String(childIndexIdList) });
|
||||||
|
context.AllProxiesMap[node.IndexId] = node;
|
||||||
|
return childNodeValidatorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
namespace ServiceLib.Handler.Builder;
|
||||||
|
|
||||||
|
public record NodeValidatorResult(List<string> Errors, List<string> Warnings)
|
||||||
|
{
|
||||||
|
public bool Success => Errors.Count == 0;
|
||||||
|
|
||||||
|
public static NodeValidatorResult Empty()
|
||||||
|
{
|
||||||
|
return new NodeValidatorResult([], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NodeValidator
|
||||||
|
{
|
||||||
|
// Static validator rules
|
||||||
|
private static readonly HashSet<string> SingboxUnsupportedTransports =
|
||||||
|
[nameof(ETransport.kcp), nameof(ETransport.xhttp)];
|
||||||
|
|
||||||
|
private static readonly HashSet<EConfigType> SingboxTransportSupportedProtocols =
|
||||||
|
[EConfigType.VMess, EConfigType.VLESS, EConfigType.Trojan, EConfigType.Shadowsocks];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> SingboxShadowsocksAllowedTransports =
|
||||||
|
[nameof(ETransport.raw), nameof(ETransport.ws)];
|
||||||
|
|
||||||
|
public static NodeValidatorResult Validate(ProfileItem item, ECoreType coreType)
|
||||||
|
{
|
||||||
|
var v = new ValidationContext();
|
||||||
|
ValidateNodeAndCoreSupport(item, coreType, v);
|
||||||
|
return v.ToResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateNodeAndCoreSupport(ProfileItem item, ECoreType coreType, ValidationContext v)
|
||||||
|
{
|
||||||
|
if (item.ConfigType is EConfigType.Custom)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ConfigType is EConfigType.Outbound)
|
||||||
|
{
|
||||||
|
if (item.CoreType != coreType)
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, coreType.ToString(), item.ConfigType));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ConfigType.IsGroupType())
|
||||||
|
{
|
||||||
|
// Group logic is handled in ValidateGroupNode
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic Property Validation
|
||||||
|
v.Assert(!item.Address.IsNullOrEmpty(), string.Format(ResUI.MsgInvalidProperty, ResUI.TbAddress));
|
||||||
|
v.Assert(item.Port is > 0 and <= 65535, string.Format(ResUI.MsgInvalidProperty, ResUI.TbPort));
|
||||||
|
|
||||||
|
// Network & Core Logic
|
||||||
|
var net = item.GetNetwork();
|
||||||
|
if (coreType == ECoreType.sing_box)
|
||||||
|
{
|
||||||
|
var transportError = ValidateSingboxTransport(item.ConfigType, net);
|
||||||
|
if (transportError != null)
|
||||||
|
{
|
||||||
|
v.Error(transportError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Global.SingboxSupportConfigType.Contains(item.ConfigType))
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, nameof(ECoreType.sing_box), item.ConfigType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (coreType is ECoreType.Xray)
|
||||||
|
{
|
||||||
|
if (!Global.XraySupportConfigType.Contains(item.ConfigType))
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, nameof(ECoreType.Xray), item.ConfigType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol Specifics
|
||||||
|
var protocolExtra = item.GetProtocolExtra();
|
||||||
|
switch (item.ConfigType)
|
||||||
|
{
|
||||||
|
case EConfigType.VMess:
|
||||||
|
v.Assert(!item.Password.IsNullOrEmpty() && Utils.IsGuidByParse(item.Password),
|
||||||
|
string.Format(ResUI.MsgInvalidProperty, ResUI.TbId));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EConfigType.VLESS:
|
||||||
|
v.Assert(
|
||||||
|
!item.Password.IsNullOrEmpty()
|
||||||
|
&& (Utils.IsGuidByParse(item.Password) || item.Password.Length <= 30),
|
||||||
|
string.Format(ResUI.MsgInvalidProperty, ResUI.TbId5)
|
||||||
|
);
|
||||||
|
v.Assert(Global.Flows.Contains(protocolExtra.Flow ?? string.Empty),
|
||||||
|
string.Format(ResUI.MsgInvalidProperty, ResUI.TbFlow5));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EConfigType.Shadowsocks:
|
||||||
|
v.Assert(!item.Password.IsNullOrEmpty(), string.Format(ResUI.MsgInvalidProperty, ResUI.TbId3));
|
||||||
|
v.Assert(
|
||||||
|
!string.IsNullOrEmpty(protocolExtra.SsMethod) &&
|
||||||
|
Global.SsSecuritiesInSingbox.Contains(protocolExtra.SsMethod),
|
||||||
|
string.Format(ResUI.MsgInvalidProperty, ResUI.TbSecurity3));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coreType is ECoreType.Xray
|
||||||
|
&& (protocolExtra.Flow ?? string.Empty).StartsWith("xtls", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& item.MuxEnabled == true)
|
||||||
|
{
|
||||||
|
v.Warning(string.Format(ResUI.MsgOptionsConflict, "XTLS", "Mux.Cool"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.GetNetwork() is nameof(ETransport.ws)
|
||||||
|
&& item.EchConfigList.IsNullOrEmpty()
|
||||||
|
&& item.GetAlpn()?.FirstOrDefault() is "h3" or "h2")
|
||||||
|
{
|
||||||
|
v.Warning(
|
||||||
|
$"WebSocket but ALPN is set to {item.Alpn}, the core may ignore the ALPN setting or cause unexpected issues.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS & Security
|
||||||
|
if (item.StreamSecurity == Global.StreamSecurity)
|
||||||
|
{
|
||||||
|
var isCertProvided = !item.Cert.IsNullOrEmpty();
|
||||||
|
if (!item.Cert.IsNullOrEmpty() && CertPemManager.ParsePemChain(item.Cert).Count == 0)
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgInvalidProperty, ResUI.TbFullCertTips));
|
||||||
|
isCertProvided = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((coreType == ECoreType.Xray
|
||||||
|
&& item.GetAllowInsecure()
|
||||||
|
&& !isCertProvided
|
||||||
|
&& item.CertSha.IsNullOrEmpty())
|
||||||
|
|| (coreType == ECoreType.sing_box
|
||||||
|
&& item.GetAllowInsecure()
|
||||||
|
&& !isCertProvided))
|
||||||
|
{
|
||||||
|
if (coreType == ECoreType.Xray)
|
||||||
|
{
|
||||||
|
v.Warning(ResUI.MsgAllowInsecureDeprecated);
|
||||||
|
}
|
||||||
|
v.Warning(ResUI.MsgInsecureConfiguration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.StreamSecurity == Global.StreamSecurityReality)
|
||||||
|
{
|
||||||
|
v.Assert(!item.PublicKey.IsNullOrEmpty(), string.Format(ResUI.MsgInvalidProperty, ResUI.TbPublicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
var transport = item.GetTransportExtra();
|
||||||
|
if (item.Network == nameof(ETransport.xhttp) && !transport.XhttpExtra.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
if (JsonUtils.ParseJson(transport.XhttpExtra) is not JsonObject)
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgInvalidProperty, ResUI.TransportExtra));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.Finalmask.IsNullOrEmpty())
|
||||||
|
{
|
||||||
|
if (JsonUtils.ParseJson(item.Finalmask) is not JsonObject)
|
||||||
|
{
|
||||||
|
v.Error(string.Format(ResUI.MsgInvalidProperty, ResUI.TbFinalmask));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateSingboxTransport(EConfigType configType, string net)
|
||||||
|
{
|
||||||
|
// sing-box does not support xhttp / kcp transports
|
||||||
|
if (SingboxUnsupportedTransports.Contains(net))
|
||||||
|
{
|
||||||
|
return string.Format(ResUI.MsgCoreNotSupportNetwork, nameof(ECoreType.sing_box), net);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sing-box does not support non-tcp transports for protocols other than vmess/trojan/vless/shadowsocks
|
||||||
|
if (!SingboxTransportSupportedProtocols.Contains(configType) && net != nameof(ETransport.raw))
|
||||||
|
{
|
||||||
|
return string.Format(ResUI.MsgCoreNotSupportProtocolTransport,
|
||||||
|
nameof(ECoreType.sing_box), configType.ToString(), net);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sing-box shadowsocks only supports tcp/ws/quic transports
|
||||||
|
if (configType == EConfigType.Shadowsocks && !SingboxShadowsocksAllowedTransports.Contains(net))
|
||||||
|
{
|
||||||
|
return string.Format(ResUI.MsgCoreNotSupportProtocolTransport,
|
||||||
|
nameof(ECoreType.sing_box), configType.ToString(), net);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ValidationContext
|
||||||
|
{
|
||||||
|
public List<string> Errors { get; } = [];
|
||||||
|
public List<string> Warnings { get; } = [];
|
||||||
|
|
||||||
|
public void Error(string message)
|
||||||
|
{
|
||||||
|
Errors.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Warning(string message)
|
||||||
|
{
|
||||||
|
Warnings.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Assert(bool condition, string errorMsg)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
{
|
||||||
|
Error(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public NodeValidatorResult ToResult()
|
||||||
|
{
|
||||||
|
return new NodeValidatorResult(Errors, Warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
using static ServiceLib.Models.ClashProxies;
|
|
||||||
|
|
||||||
namespace ServiceLib.Handler;
|
|
||||||
|
|
||||||
public sealed class ClashApiHandler
|
|
||||||
{
|
|
||||||
private static readonly Lazy<ClashApiHandler> instance = new(() => new());
|
|
||||||
public static ClashApiHandler Instance => instance.Value;
|
|
||||||
|
|
||||||
private static readonly string _tag = "ClashApiHandler";
|
|
||||||
private Dictionary<string, ProxiesItem>? _proxies;
|
|
||||||
public Dictionary<string, object> ProfileContent { get; set; }
|
|
||||||
|
|
||||||
public async Task<Tuple<ClashProxies, ClashProviders>?> GetClashProxiesAsync()
|
|
||||||
{
|
|
||||||
for (var i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/proxies";
|
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
|
||||||
var clashProxies = JsonUtils.Deserialize<ClashProxies>(result);
|
|
||||||
|
|
||||||
var url2 = $"{GetApiUrl()}/providers/proxies";
|
|
||||||
var result2 = await HttpClientHelper.Instance.TryGetAsync(url2);
|
|
||||||
var clashProviders = JsonUtils.Deserialize<ClashProviders>(result2);
|
|
||||||
|
|
||||||
if (clashProxies != null || clashProviders != null)
|
|
||||||
{
|
|
||||||
_proxies = clashProxies?.proxies;
|
|
||||||
return new Tuple<ClashProxies, ClashProviders>(clashProxies, clashProviders);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClashProxiesDelayTest(bool blAll, List<ClashProxyModel> lstProxy, Action<ClashProxyModel?, string> updateFunc)
|
|
||||||
{
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
if (blAll)
|
|
||||||
{
|
|
||||||
if (_proxies == null)
|
|
||||||
{
|
|
||||||
await GetClashProxiesAsync();
|
|
||||||
}
|
|
||||||
lstProxy = new List<ClashProxyModel>();
|
|
||||||
foreach (var kv in _proxies ?? [])
|
|
||||||
{
|
|
||||||
if (Global.notAllowTestType.Contains(kv.Value.type?.ToLower()))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lstProxy.Add(new ClashProxyModel()
|
|
||||||
{
|
|
||||||
Name = kv.Value.name,
|
|
||||||
Type = kv.Value.type?.ToLower(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lstProxy is not { Count: > 0 })
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var urlBase = $"{GetApiUrl()}/proxies";
|
|
||||||
urlBase += @"/{0}/delay?timeout=10000&url=" + AppHandler.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
|
|
||||||
|
|
||||||
var tasks = new List<Task>();
|
|
||||||
foreach (var it in lstProxy)
|
|
||||||
{
|
|
||||||
if (Global.notAllowTestType.Contains(it.Type.ToLower()))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
var name = it.Name;
|
|
||||||
var url = string.Format(urlBase, name);
|
|
||||||
tasks.Add(Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
|
||||||
updateFunc?.Invoke(it, result);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
await Task.Delay(1000);
|
|
||||||
updateFunc?.Invoke(null, "");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ProxiesItem>? GetClashProxyGroups()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fileContent = ProfileContent;
|
|
||||||
if (fileContent is null || fileContent?.ContainsKey("proxy-groups") == false)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return JsonUtils.Deserialize<List<ProxiesItem>>(JsonUtils.Serialize(fileContent["proxy-groups"]));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClashSetActiveProxy(string name, string nameNode)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/proxies/{name}";
|
|
||||||
var headers = new Dictionary<string, string>();
|
|
||||||
headers.Add("name", nameNode);
|
|
||||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClashConfigUpdate(Dictionary<string, string> headers)
|
|
||||||
{
|
|
||||||
if (_proxies == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var urlBase = $"{GetApiUrl()}/configs";
|
|
||||||
|
|
||||||
await HttpClientHelper.Instance.PatchAsync(urlBase, headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClashConfigReload(string filePath)
|
|
||||||
{
|
|
||||||
await ClashConnectionClose("");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/configs?force=true";
|
|
||||||
var headers = new Dictionary<string, string>();
|
|
||||||
headers.Add("path", filePath);
|
|
||||||
await HttpClientHelper.Instance.PutAsync(url, headers);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ClashConnections?> GetClashConnectionsAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/connections";
|
|
||||||
var result = await HttpClientHelper.Instance.TryGetAsync(url);
|
|
||||||
var clashConnections = JsonUtils.Deserialize<ClashConnections>(result);
|
|
||||||
|
|
||||||
return clashConnections;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ClashConnectionClose(string id)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{GetApiUrl()}/connections/{id}";
|
|
||||||
await HttpClientHelper.Instance.DeleteAsync(url);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logging.SaveLog(_tag, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetApiUrl()
|
|
||||||
{
|
|
||||||
return $"{Global.HttpProtocol}{Global.Loopback}:{AppHandler.Instance.StatePort2}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user