diff options
| author | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
|---|---|---|
| committer | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
| commit | d608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch) | |
| tree | 3824ec9da5ca0adac913cc47e3e20054c3dc843a | |
| parent | 40cf3276cdc74bf6f1795990ed600891a637d1fe (diff) | |
Implement Linux proxy controller, subscription state, and CLI/TUI
| -rw-r--r-- | Cargo.lock | 2859 | ||||
| -rw-r--r-- | Cargo.toml | 30 | ||||
| -rw-r--r-- | README.md | 55 | ||||
| -rw-r--r-- | assets/org.roentgen.Controller1.conf | 9 | ||||
| -rw-r--r-- | assets/org.roentgen.policy | 14 | ||||
| -rw-r--r-- | assets/roentgen-guard.service | 18 | ||||
| -rw-r--r-- | assets/roentgen-xray.service | 19 | ||||
| -rw-r--r-- | assets/roentgen.service | 23 | ||||
| -rw-r--r-- | src/authentication.rs | 52 | ||||
| -rw-r--r-- | src/bus.rs | 357 | ||||
| -rw-r--r-- | src/controller.rs | 478 | ||||
| -rw-r--r-- | src/daemon.rs | 74 | ||||
| -rw-r--r-- | src/diagnostics.rs | 172 | ||||
| -rw-r--r-- | src/editor.rs | 26 | ||||
| -rw-r--r-- | src/installation.rs | 152 | ||||
| -rw-r--r-- | src/lib.rs | 16 | ||||
| -rw-r--r-- | src/linux/firewall.rs | 234 | ||||
| -rw-r--r-- | src/linux/mod.rs | 119 | ||||
| -rw-r--r-- | src/linux/routes.rs | 186 | ||||
| -rw-r--r-- | src/locking.rs | 35 | ||||
| -rw-r--r-- | src/main.rs | 244 | ||||
| -rw-r--r-- | src/model.rs | 147 | ||||
| -rw-r--r-- | src/runtime.rs | 34 | ||||
| -rw-r--r-- | src/storage.rs | 264 | ||||
| -rw-r--r-- | src/subscription.rs | 305 | ||||
| -rw-r--r-- | src/tui.rs | 565 | ||||
| -rw-r--r-- | src/xray.rs | 206 | ||||
| -rw-r--r-- | tests/cli_acceptance.rs | 31 | ||||
| -rw-r--r-- | tests/controller_acceptance.rs | 355 | ||||
| -rw-r--r-- | tests/vm_controller_acceptance.py | 144 | ||||
| -rw-r--r-- | tests/vm_guard_acceptance.py | 158 |
31 files changed, 7379 insertions, 2 deletions
diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d06b956 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2859 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mnl" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ab362254419b09757228dc96e0d834e75392c52870d759df2274753007065" +dependencies = [ + "libc", + "log", + "mnl-sys", +] + +[[package]] +name = "mnl-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f3c607af12ed53f3cade8ee7adeced5533800abc0b07f5b6bdc7081d1e0d4c" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + +[[package]] +name = "netlink-packet-route" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", + "zerocopy", +] + +[[package]] +name = "netlink-proto" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e03ae6247c6cdb499e8f14e98c72b83954d85822b687056e3a21150b438f2b" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror", +] + +[[package]] +name = "netlink-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nftnl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6d338cb46ff5b5614179474ce3775cf309f241ab512056a24428a13a89c671" +dependencies = [ + "bitflags", + "log", + "nftnl-sys", + "nix 0.31.3", +] + +[[package]] +name = "nftnl-sys" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffa28b4c81eea6a8a4fd454634d425e1a559250a50a926de4517dd9607fe9a4" +dependencies = [ + "cfg-if", + "libc", + "mnl-sys", + "pkg-config", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roentgen" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "clap", + "crossterm", + "futures-util", + "httpdate", + "libc", + "mnl", + "nftnl", + "percent-encoding", + "ratatui", + "reqwest", + "rtnetlink", + "rusqlite", + "serde", + "serde_json", + "shell-words", + "tempfile", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "zbus", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +dependencies = [ + "thiserror", +] + +[[package]] +name = "rtnetlink" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f60a3ee149005c5819e03e3d73ba4a470bc18b52b0c8422cfe1bea2cae63c6" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror", + "tokio", +] + +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +dependencies = [ + "cc", + "rsqlite-vfs", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.6", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] @@ -4,3 +4,33 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0.104" +async-trait = "0.1.92" +base64 = "0.23.1" +clap = { version = "4.6.7", features = ["derive"] } +crossterm = "0.28" +futures-util = "0.3.34" +httpdate = "1.0.3" +libc = "0.2.191" +mnl = { version = "0.3.1", optional = true } +nftnl = { version = "0.9.4", optional = true } +percent-encoding = "2.3.2" +ratatui = "0.29" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream", "socks"] } +rtnetlink = "0.23" +rusqlite = { version = "0.41.1", features = ["bundled"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +shell-words = "1.1.1" +tempfile = "3.27.0" +tokio = { version = "1.54.1", features = ["full"] } +tokio-util = { version = "0.7.20", features = ["io"] } +toml = "1.1.8" +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +url = "2.5.8" +uuid = { version = "1.29.0", features = ["v4", "serde"] } +zbus = { version = "5", default-features = false, features = ["tokio"] } + +[features] +linux-runtime = ["dep:nftnl", "dep:mnl"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecd2380 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Roentgen + +Roentgen is a Linux CLI/TUI for importing 3x-ui subscription link lists and controlling the local machine's proxy intent through a root-owned controller. The implementation follows [.scratch/linux-first-release/spec.md](.scratch/linux-first-release/spec.md). + +This is an implementation candidate. Full native-TUN, boot, SSH authentication and recovery acceptance on the required current ALT Xray package is incomplete. The available ALT test VM and its refreshed Sisyphus repository currently supply Xray 26.3.27. Activation rejects that release before changing enabled intent. This build currently gates activation on the researched Xray 26.10.29 configuration contract. That gate is not a claim that the full release acceptance matrix has passed. + +## Build + +Use Rust with Cargo. The Linux runtime additionally needs ALT's `libnftnl-devel` and `libmnl-devel` packages. SQLite is bundled into the executable; Xray remains the distribution-owned `/usr/bin/xray` executable. + +```sh +cargo build --release --features linux-runtime +``` + +The default build contains the CLI/TUI and controller library. Installation and daemon execution require the `linux-runtime` feature. Roentgen never downloads or replaces Xray. + +## Install and use + +Run the privileged installation step after validating the runtime on a disposable ALT VM: + +```sh +sudo target/release/roentgen install +roentgen subscription add --name Personal +roentgen configuration list +roentgen configuration select ID +roentgen enable +roentgen status --json +roentgen disable +``` + +Subscription add/edit reads its secret URL from a prompt or one stdin line. Do not put it in a command argument. Import never chooses a configuration or enables proxying. Invalid and unsupported entries remain visible. An accepted refresh can clear selection while leaving enabled intent and protection in place. + +Run `roentgen` to open the TUI. Use h/l to switch screens, j/k to move, Enter to select/open, Space to enable/disable, s to choose a viewed subscription, J/K to cycle subscriptions, / to filter, d for details, t for Ping, b for Speed, x to unset, c for the console, e for the editor and ? for help. Text fields preserve literal typing. Closing the TUI leaves the background service running. + +Mutations use server-side polkit authorization. Noninteractive commands never request interactive authentication. Interactive commands register a terminal polkit agent. Public status and lists omit URLs, passwords and raw imported links. + +Settings and subscription definitions live in `/etc/roentgen/config.toml`; retained entries, explicit selection and enabled intent live in `/var/lib/roentgen/state.sqlite3`. Runtime files belong under `/run/roentgen`. Refresh changes and selection reconciliation commit in one SQLite transaction. The early guard reads its separate durable input from `/etc/roentgen/guard.toml`. + +## Recovery and removal + +```sh +sudo roentgen recover --disable +sudo roentgen uninstall +sudo roentgen uninstall --purge +``` + +Explicit recovery persists disabled intent before releasing owned capture and protection. Default uninstall retains saved configuration/data. Purge also removes them. Neither operation removes the system Xray package. The dedicated service account remains to avoid reassigning its UID to an unrelated process. + +## Validation + +Run tests only on the supplied disposable ALT VM. Compile acceptance executables with `cargo test --features linux-runtime --no-run`; copy the executable and test artifacts to the VM or run Cargo there. CLI tests support `ROENTGEN_TEST_BINARY` when the executable is installed at another path. + +`tests/controller_acceptance.rs` exercises the public controller with real TOML/SQLite persistence and HTTP/runtime boundary fixtures. `tests/cli_acceptance.rs` exercises the executable interface. `tests/vm_guard_acceptance.py` checks real kernel traffic through the guard CLI. `tests/vm_controller_acceptance.py` checks the CLI/system-bus/polkit boundary, using temporary integration fixtures when the packaged Xray is too old for product installation. The Python checks refuse to run outside the authorized `altctlrs` VM and require its explicit VM-only marker. + +The VM guard checks use a temporary network namespace and veth pair, never the workstation's network. Preserve a powered-off checkpoint before running them, and restore it after validation. Credentials and VM reset settings belong outside this repository. diff --git a/assets/org.roentgen.Controller1.conf b/assets/org.roentgen.Controller1.conf new file mode 100644 index 0000000..1fe7e39 --- /dev/null +++ b/assets/org.roentgen.Controller1.conf @@ -0,0 +1,9 @@ +<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd"> +<busconfig> + <policy user="root"> + <allow own="org.roentgen.Controller1"/> + </policy> + <policy context="default"> + <allow send_destination="org.roentgen.Controller1"/> + </policy> +</busconfig> diff --git a/assets/org.roentgen.policy b/assets/org.roentgen.policy new file mode 100644 index 0000000..aceff5e --- /dev/null +++ b/assets/org.roentgen.policy @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd"> +<policyconfig> + <vendor>Roentgen</vendor> + <action id="org.roentgen.control"> + <description>Control this machine's proxy and saved subscriptions</description> + <message>Administrator authentication is required to control Roentgen</message> + <defaults> + <allow_any>auth_admin</allow_any> + <allow_inactive>auth_admin</allow_inactive> + <allow_active>auth_admin</allow_active> + </defaults> + </action> +</policyconfig> diff --git a/assets/roentgen-guard.service b/assets/roentgen-guard.service new file mode 100644 index 0000000..8f04600 --- /dev/null +++ b/assets/roentgen-guard.service @@ -0,0 +1,18 @@ +[Unit] +Description=Roentgen persistent traffic guard and incoming-flow classification +DefaultDependencies=no +After=local-fs.target +Before=network-pre.target NetworkManager.service systemd-networkd.service roentgen.service +Wants=network-pre.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/roentgen guard +RemainAfterExit=yes +UMask=0077 +NoNewPrivileges=yes +ProtectHome=yes +ProtectSystem=strict + +[Install] +WantedBy=network-pre.target diff --git a/assets/roentgen-xray.service b/assets/roentgen-xray.service new file mode 100644 index 0000000..7cbdaea --- /dev/null +++ b/assets/roentgen-xray.service @@ -0,0 +1,19 @@ +[Unit] +Description=Roentgen-owned system Xray process +Requires=roentgen-guard.service +After=roentgen-guard.service + +[Service] +Type=simple +User=roentgen-xray +Group=roentgen-xray +ExecStart=/usr/bin/xray run -config /run/roentgen/xray.json +Restart=on-failure +RestartSec=5 +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +UMask=0077 diff --git a/assets/roentgen.service b/assets/roentgen.service new file mode 100644 index 0000000..c988d7d --- /dev/null +++ b/assets/roentgen.service @@ -0,0 +1,23 @@ +[Unit] +Description=Roentgen proxy controller +Requires=roentgen-guard.service +After=roentgen-guard.service dbus.service + +[Service] +Type=dbus +BusName=org.roentgen.Controller1 +ExecStart=/usr/local/bin/roentgen daemon +Restart=on-failure +RestartSec=5 +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ReadWritePaths=/etc/roentgen /var/lib/roentgen /run/roentgen +RuntimeDirectory=roentgen +RuntimeDirectoryMode=0750 +RuntimeDirectoryPreserve=yes + +[Install] +WantedBy=multi-user.target diff --git a/src/authentication.rs b/src/authentication.rs new file mode 100644 index 0000000..5312792 --- /dev/null +++ b/src/authentication.rs @@ -0,0 +1,52 @@ +use anyhow::{Context, Result}; +use std::{ + io::Read, + os::fd::AsRawFd, + process::{Child, Command, Stdio}, + time::Duration, +}; + +pub struct TerminalAgent(Option<Child>); +impl TerminalAgent { + pub fn start(interactive: bool) -> Result<Self> { + if !interactive || unsafe { libc::geteuid() } == 0 { + return Ok(Self(None)); + } + let (mut reader, writer) = std::os::unix::net::UnixStream::pair()?; + reader.set_read_timeout(Some(Duration::from_secs(5)))?; + let fd = writer.as_raw_fd(); + // SAFETY: fd belongs to writer and remains open until after spawning the agent. + unsafe { + libc::fcntl(fd, libc::F_SETFD, 0); + } + let mut child = Command::new("/usr/bin/pkttyagent") + .args([ + "--process", + &std::process::id().to_string(), + "--notify-fd", + &fd.to_string(), + "--fallback", + ]) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("Could not start terminal polkit agent; install polkit")?; + drop(writer); + let mut buffer = [0u8; 1]; + if let Err(error) = reader.read(&mut buffer) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error).context("Terminal authentication agent did not become ready"); + } + Ok(Self(Some(child))) + } +} +impl Drop for TerminalAgent { + fn drop(&mut self) { + if let Some(child) = &mut self.0 { + let _ = child.kill(); + let _ = child.wait(); + } + } +} diff --git a/src/bus.rs b/src/bus.rs new file mode 100644 index 0000000..fd26f1d --- /dev/null +++ b/src/bus.rs @@ -0,0 +1,357 @@ +use crate::controller::{Caller, Controller, Operation}; +use anyhow::{Result, anyhow}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; +use zbus::{Connection, message::Header, zvariant::Value}; + +pub const NAME: &str = "org.roentgen.Controller1"; +pub const PATH: &str = "/org/roentgen/Controller1"; +pub const INTERFACE: &str = "org.roentgen.Controller1"; +pub type SharedController = Arc<Mutex<Controller>>; + +pub struct Service { + pub controller: SharedController, + pub cancellation: Arc<tokio::sync::Notify>, +} + +async fn authorize( + connection: &Connection, + header: &Header<'_>, + interactive: bool, +) -> zbus::fdo::Result<Caller> { + let sender = header + .sender() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing bus sender".into()))?; + let bus = zbus::fdo::DBusProxy::new(connection).await?; + let uid = bus.get_connection_unix_user(sender.clone().into()).await?; + if uid == 0 { + return Ok(Caller::authorized()); + } + let authority = zbus::Proxy::new( + connection, + "org.freedesktop.PolicyKit1", + "/org/freedesktop/PolicyKit1/Authority", + "org.freedesktop.PolicyKit1.Authority", + ) + .await + .map_err(|_| zbus::fdo::Error::AccessDenied("Polkit is unavailable".into()))?; + let mut subject = HashMap::new(); + subject.insert("name", Value::from(sender.as_str())); + let result: (bool, bool, HashMap<String, String>) = authority + .call( + "CheckAuthorization", + &( + ("system-bus-name", subject), + "org.roentgen.control", + HashMap::<String, String>::new(), + u32::from(interactive), + "", + ), + ) + .await + .map_err(|_| zbus::fdo::Error::AccessDenied("Authorization denied or cancelled".into()))?; + if !result.0 { + return Err(zbus::fdo::Error::AccessDenied( + "Administrator authorization required; no state changed".into(), + )); + } + Ok(Caller::authorized()) +} + +fn failure(error: anyhow::Error) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(error.to_string()) +} +fn json<T: serde::Serialize>(value: T) -> zbus::fdo::Result<String> { + serde_json::to_string(&value) + .map_err(|_| zbus::fdo::Error::Failed("Could not encode response".into())) +} + +impl Service { + async fn change( + &self, + operation: Operation, + interactive: bool, + connection: &Connection, + header: &Header<'_>, + ) -> zbus::fdo::Result<()> { + let caller = authorize(connection, header, interactive).await?; + self.controller + .lock() + .await + .execute(&caller, operation) + .await + .map_err(failure) + } +} + +#[zbus::interface(name = "org.roentgen.Controller1")] +impl Service { + async fn status(&self) -> zbus::fdo::Result<String> { + json(self.controller.lock().await.status().map_err(failure)?) + } + async fn subscriptions(&self) -> zbus::fdo::Result<String> { + json(self.controller.lock().await.subscriptions()) + } + async fn configurations(&self, subscription: &str) -> zbus::fdo::Result<String> { + json( + self.controller + .lock() + .await + .configurations((!subscription.is_empty()).then_some(subscription)) + .map_err(failure)?, + ) + } + async fn logs(&self) -> zbus::fdo::Result<String> { + json(self.controller.lock().await.logs()) + } + async fn diagnostic( + &self, + id: &str, + speed: bool, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<String> { + let caller = authorize(connection, &header, interactive).await?; + let mut controller = self.controller.lock().await; + let cancellation = self.cancellation.notified(); + tokio::pin!(cancellation); + cancellation.as_mut().enable(); + tokio::select! { + result = controller.diagnostic(&caller, (!id.is_empty()).then_some(id), speed) => json(result.map_err(failure)?), + _ = cancellation => Err(zbus::fdo::Error::Failed("Diagnostic cancelled; proxy intent is unchanged".into())), + } + } + async fn cancel_diagnostic( + &self, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + let _caller = authorize(connection, &header, interactive).await?; + self.cancellation.notify_waiters(); + Ok(()) + } + async fn settings(&self) -> zbus::fdo::Result<String> { + json(self.controller.lock().await.settings()) + } + async fn add_subscription( + &self, + name: &str, + url: &str, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change( + Operation::AddSubscription { + name: name.into(), + url: url.into(), + }, + interactive, + connection, + &header, + ) + .await + } + async fn edit_subscription( + &self, + name: &str, + url: &str, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change( + Operation::EditSubscription { + name: name.into(), + url: url.into(), + }, + interactive, + connection, + &header, + ) + .await + } + async fn delete_subscription( + &self, + name: &str, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change( + Operation::DeleteSubscription { name: name.into() }, + interactive, + connection, + &header, + ) + .await + } + async fn refresh( + &self, + name: &str, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change( + Operation::Refresh { + name: (!name.is_empty()).then(|| name.into()), + }, + interactive, + connection, + &header, + ) + .await + } + async fn select( + &self, + id: &str, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change( + Operation::Select { id: id.into() }, + interactive, + connection, + &header, + ) + .await + } + async fn unset( + &self, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change(Operation::Unset, interactive, connection, &header) + .await + } + async fn enable( + &self, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change(Operation::Enable, interactive, connection, &header) + .await + } + async fn disable( + &self, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.change(Operation::Disable, interactive, connection, &header) + .await + } + async fn save_settings( + &self, + value: &str, + interactive: bool, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + let settings = serde_json::from_str(value) + .map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid settings".into()))?; + self.change( + Operation::SaveSettings { settings }, + interactive, + connection, + &header, + ) + .await + } +} + +pub struct Client { + connection: Connection, +} +impl Client { + pub async fn system() -> Result<Self> { + Ok(Self { + connection: Connection::system() + .await + .map_err(|_| anyhow!("Cannot connect to the system bus"))?, + }) + } + pub fn from_connection(connection: Connection) -> Self { + Self { connection } + } + async fn proxy(&self) -> Result<zbus::Proxy<'_>> { + zbus::Proxy::new(&self.connection, NAME, PATH, INTERFACE) + .await + .map_err(|_| anyhow!("Cannot contact Roentgen; install and start roentgen.service")) + } + pub async fn read<T: serde::de::DeserializeOwned>(&self, method: &str) -> Result<T> { + let value: String = self.proxy().await?.call(method, &()).await?; + Ok(serde_json::from_str(&value)?) + } + pub async fn configurations( + &self, + name: Option<&str>, + ) -> Result<Vec<crate::model::ConfigurationSummary>> { + let value: String = self + .proxy() + .await? + .call("Configurations", &(name.unwrap_or(""),)) + .await?; + Ok(serde_json::from_str(&value)?) + } + pub async fn diagnostic( + &self, + id: Option<&str>, + speed: bool, + interactive: bool, + ) -> Result<String> { + let proxy = self.proxy().await?; + let arguments = (id.unwrap_or(""), speed, interactive); + tokio::select! { + result = proxy.call("Diagnostic", &arguments) => Ok(result?), + _ = tokio::signal::ctrl_c() => { + let _: () = proxy.call("CancelDiagnostic", &(interactive,)).await?; + anyhow::bail!("Diagnostic cancelled; proxy intent is unchanged") + } + } + } + pub async fn change(&self, operation: Operation, interactive: bool) -> Result<()> { + let proxy = self.proxy().await?; + match operation { + Operation::AddSubscription { name, url } => { + proxy + .call("AddSubscription", &(name, url, interactive)) + .await? + } + Operation::EditSubscription { name, url } => { + proxy + .call("EditSubscription", &(name, url, interactive)) + .await? + } + Operation::DeleteSubscription { name } => { + proxy + .call("DeleteSubscription", &(name, interactive)) + .await? + } + Operation::Refresh { name } => { + proxy + .call("Refresh", &(name.unwrap_or_default(), interactive)) + .await? + } + Operation::Select { id } => proxy.call("Select", &(id, interactive)).await?, + Operation::Unset => proxy.call("Unset", &(interactive,)).await?, + Operation::Enable => proxy.call("Enable", &(interactive,)).await?, + Operation::Disable => proxy.call("Disable", &(interactive,)).await?, + Operation::SaveSettings { settings } => { + proxy + .call( + "SaveSettings", + &(serde_json::to_string(&settings)?, interactive), + ) + .await? + } + } + Ok(()) + } +} diff --git a/src/controller.rs b/src/controller.rs new file mode 100644 index 0000000..1384ad8 --- /dev/null +++ b/src/controller.rs @@ -0,0 +1,478 @@ +use crate::{ + model::*, + runtime::Runtime, + storage::Storage, + subscription::{SubscriptionSource, import, subscription_url}, +}; +use anyhow::{Result, anyhow, ensure}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use uuid::Uuid; + +/// Constructed by the transport after resolving the real bus sender and checking polkit. +pub struct Caller { + authorized: bool, +} +impl Caller { + pub fn root() -> Self { + Self { authorized: true } + } + pub fn denied() -> Self { + Self { authorized: false } + } + pub(crate) fn authorized() -> Self { + Self { authorized: true } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum Operation { + AddSubscription { name: String, url: String }, + EditSubscription { name: String, url: String }, + DeleteSubscription { name: String }, + Refresh { name: Option<String> }, + Select { id: String }, + Unset, + Enable, + Disable, + SaveSettings { settings: Settings }, +} + +pub struct Controller { + storage: Storage, + runtime: Box<dyn Runtime>, + source: Box<dyn SubscriptionSource>, + last_error: Option<String>, + observation: Option<crate::diagnostics::Observation>, + logs: std::collections::VecDeque<String>, + next_check: Option<tokio::time::Instant>, + measurements: Vec<crate::diagnostics::Observation>, +} + +impl Controller { + pub fn open( + root: &Path, + runtime: Box<dyn Runtime>, + source: Box<dyn SubscriptionSource>, + ) -> Result<Self> { + Self::open_paths(&root.join("etc"), &root.join("data"), runtime, source) + } + + pub fn open_paths( + config: &Path, + data: &Path, + runtime: Box<dyn Runtime>, + source: Box<dyn SubscriptionSource>, + ) -> Result<Self> { + Ok(Self { + storage: Storage::open(config, data)?, + runtime, + source, + last_error: None, + observation: None, + logs: std::collections::VecDeque::new(), + next_check: Some(tokio::time::Instant::now()), + measurements: vec![], + }) + } + + pub fn status(&self) -> Result<Status> { + let (requested_enabled, selected_configuration, generation) = self.storage.state()?; + Ok(Status { + version: 1, + requested_enabled, + selected_configuration, + generation, + routing: self.runtime.routing().into(), + health: self + .observation + .as_ref() + .filter(|o| o.generation == generation) + .map(|o| { + if o.success { + "reachable" + } else { + "check-failed" + } + }) + .unwrap_or("unknown") + .into(), + last_error: self.last_error.clone(), + }) + } + + pub fn subscriptions(&self) -> Vec<SubscriptionSummary> { + self.storage + .definitions + .subscriptions + .iter() + .map(|s| SubscriptionSummary { + id: s.id.clone(), + name: s.name.clone(), + }) + .collect() + } + + pub fn settings(&self) -> Settings { + self.storage.definitions.settings.clone() + } + + pub fn configurations(&self, name: Option<&str>) -> Result<Vec<ConfigurationSummary>> { + let (_, selected, _) = self.storage.state()?; + if let Some(name) = name { + self.subscription(name)?; + } + let mut rows = vec![]; + for subscription in &self.storage.definitions.subscriptions { + if name.is_some_and(|name| name != subscription.name) { + continue; + } + for entry in self.storage.entries(&subscription.id)? { + let ping = self + .measurements + .iter() + .find(|o| o.configuration_id == entry.id && o.kind == "ping" && o.success); + let speed = self + .measurements + .iter() + .find(|o| o.configuration_id == entry.id && o.kind == "speed" && o.success); + rows.push(ConfigurationSummary { + ping_ms: ping.map(|o| o.elapsed_ms), + speed_bytes_per_second: speed.and_then(|o| o.bytes_per_second), + selected: Some(&entry.id) == selected.as_ref(), + id: entry.id, + subscription_id: subscription.id.clone(), + name: entry.name, + host: entry.connection.as_ref().map(|c| c.host.clone()), + port: entry.connection.as_ref().map(|c| c.port), + status: entry.status, + explanation: entry.explanation, + }); + } + } + Ok(rows) + } + + pub async fn restore(&mut self) -> Result<()> { + if self.storage.state()?.0 { + self.runtime.arm().await?; + self.reconcile().await?; + } else { + self.runtime.disable().await?; + } + Ok(()) + } + + pub async fn execute(&mut self, caller: &Caller, operation: Operation) -> Result<()> { + ensure!( + caller.authorized, + "Administrator authorization required; no state changed" + ); + self.last_error = None; + self.record("Authorized controller operation".into()); + match operation { + Operation::AddSubscription { name, url } => { + validate_name(&name)?; + subscription_url(&url)?; + ensure!( + !self + .storage + .definitions + .subscriptions + .iter() + .any(|s| s.name == name), + "A subscription with this name already exists" + ); + let entries = import(&self.source.fetch(&url).await?)?; + let subscription = Subscription { + id: Uuid::new_v4().to_string(), + name, + url, + }; + // Orphan snapshots after a failed TOML write are removed on next startup. + self.storage.replace_entries(&subscription.id, &entries)?; + let mut definitions = self.storage.definitions.clone(); + definitions.subscriptions.push(subscription); + self.storage.save_definitions(&definitions)?; + self.storage.definitions = definitions; + } + Operation::EditSubscription { name, url } => { + subscription_url(&url)?; + let id = self.subscription(&name)?.id.clone(); + let mut definitions = self.storage.definitions.clone(); + definitions + .subscriptions + .iter_mut() + .find(|s| s.id == id) + .unwrap() + .url = url; + self.storage.save_definitions(&definitions)?; + self.storage.definitions = definitions; + } + Operation::DeleteSubscription { name } => { + let id = self.subscription(&name)?.id.clone(); + let mut definitions = self.storage.definitions.clone(); + definitions.subscriptions.retain(|s| s.id != id); + self.storage.save_definitions(&definitions)?; + self.storage.definitions = definitions; + self.storage.delete_entries(&id)?; + self.reconcile().await?; + } + Operation::Refresh { name } => { + let subscriptions = if let Some(name) = name { + vec![self.subscription(&name)?.clone()] + } else { + self.storage.definitions.subscriptions.clone() + }; + let mut failures = 0; + for subscription in subscriptions { + let result = self.refresh(&subscription).await; + if let Err(error) = result { + failures += 1; + self.last_error = Some(error.to_string()); + } + } + ensure!( + failures == 0, + "{} subscription update(s) failed; {}", + failures, + self.last_error.as_deref().unwrap_or("retry update") + ); + } + Operation::Select { id } => { + self.usable_entry(&id)?; + self.storage.select(Some(&id))?; + self.reconcile().await?; + } + Operation::Unset => { + self.storage.select(None)?; + self.reconcile().await?; + } + Operation::Enable => { + let (_, selected, _) = self.storage.state()?; + let id = selected + .ok_or_else(|| anyhow!("Select a supported configuration before enabling"))?; + self.usable_entry(&id)?; + self.runtime.arm().await?; + self.storage.set_intent(true)?; + self.reconcile().await?; + } + Operation::Disable => { + self.storage.set_intent(false)?; + self.runtime.disable().await?; + } + Operation::SaveSettings { settings } => { + settings.validate()?; + let mut definitions = self.storage.definitions.clone(); + definitions.settings = settings; + self.storage.save_definitions(&definitions)?; + self.storage.definitions = definitions; + } + } + Ok(()) + } + + fn record(&mut self, message: String) { + self.logs.push_back(message); + if self.logs.len() > 200 { + self.logs.pop_front(); + } + } + pub fn report_runtime_error(&mut self, error: &anyhow::Error) { + self.last_error = Some(error.to_string()); + self.record(error.to_string()); + } + pub fn logs(&self) -> Vec<String> { + self.logs.iter().cloned().collect() + } + + pub async fn diagnostic( + &mut self, + caller: &Caller, + id: Option<&str>, + speed: bool, + ) -> Result<crate::diagnostics::Observation> { + ensure!( + caller.authorized, + "Administrator authorization required; no state changed" + ); + let (_, selected, generation) = self.storage.state()?; + let id = id + .or(selected.as_deref()) + .ok_or_else(|| anyhow!("Select a configuration or supply its ID before testing"))?; + let entry = self.usable_entry(id)?; + let settings = self.settings(); + let probe = crate::xray::Probe::start( + entry.connection.as_ref().unwrap(), + if speed { + settings.maximum_duration_seconds + } else { + 5 + }, + ) + .await?; + let result = + crate::diagnostics::measure(&settings, &probe.proxy, id.into(), generation, speed) + .await; + probe.stop().await; + match result { + Ok(observation) => { + if !speed && Some(id) == selected.as_deref() { + let wait = observation.retry_after_seconds.unwrap_or(30).max(30); + self.next_check = tokio::time::Instant::now() + .checked_add(std::time::Duration::from_secs(wait)); + self.observation = Some(observation.clone()); + } + self.measurements + .retain(|o| o.configuration_id != id || o.kind != observation.kind); + self.measurements.push(observation.clone()); + self.record(format!( + "{} observation: {}", + if speed { "Speed" } else { "Ping" }, + observation.stop_reason + )); + Ok(observation) + } + Err(error) => { + if !speed && Some(id) == selected.as_deref() { + self.observation = Some(crate::diagnostics::Observation { + configuration_id: id.into(), + generation, + kind: "ping".into(), + success: false, + elapsed_ms: 0, + bytes: 0, + bytes_per_second: None, + stop_reason: error.to_string(), + retry_after_seconds: None, + }); + } + self.record(error.to_string()); + Err(error) + } + } + } + + pub async fn scheduled_check(&mut self) -> Result<()> { + let (enabled, selected, generation) = self.storage.state()?; + if enabled { + let entry = selected + .as_deref() + .map(|id| self.usable_entry(id)) + .transpose()?; + self.runtime + .maintain( + entry.as_ref().and_then(|e| e.connection.as_ref()), + generation, + ) + .await?; + } + if selected.is_some() + && self + .next_check + .is_some_and(|deadline| tokio::time::Instant::now() >= deadline) + { + self.next_check = + Some(tokio::time::Instant::now() + std::time::Duration::from_secs(30)); + self.diagnostic(&Caller::root(), None, false).await?; + } + Ok(()) + } + + async fn refresh(&mut self, subscription: &Subscription) -> Result<()> { + let mut entries = import(&self.source.fetch(&subscription.url).await?)?; + let old = self.storage.entries(&subscription.id)?; + // Match normalized content, then a unique endpoint/authentication tuple, never labels or positions. + let mut matched_old = std::collections::HashSet::new(); + let mut matched_new = std::collections::HashSet::new(); + for exact in [true, false] { + for i in 0..entries.len() { + if matched_new.contains(&i) { + continue; + } + let Some(connection) = entries[i].connection.as_ref() else { + continue; + }; + let matches = |candidate: &Trojan| { + if exact { + candidate == connection + } else { + candidate.identity() == connection.identity() + } + }; + let candidates: Vec<_> = old + .iter() + .enumerate() + .filter(|(j, old)| { + !matched_old.contains(j) && old.connection.as_ref().is_some_and(matches) + }) + .collect(); + let new_count = entries + .iter() + .enumerate() + .filter(|(j, e)| { + !matched_new.contains(j) && e.connection.as_ref().is_some_and(matches) + }) + .count(); + if candidates.len() == 1 && new_count == 1 { + entries[i].id = candidates[0].1.id.clone(); + matched_old.insert(candidates[0].0); + matched_new.insert(i); + } + } + } + let affected: std::collections::HashSet<_> = old.iter().map(|e| e.id.as_str()).collect(); + self.measurements + .retain(|o| !affected.contains(o.configuration_id.as_str())); + self.storage.replace_entries(&subscription.id, &entries)?; + self.reconcile().await + } + + fn subscription(&self, name: &str) -> Result<&Subscription> { + self.storage + .definitions + .subscriptions + .iter() + .find(|s| s.name == name) + .ok_or_else(|| anyhow!("Subscription not found; run subscription list")) + } + + pub fn usable_entry(&self, id: &str) -> Result<Entry> { + let entry = self + .storage + .find_entry(id)? + .ok_or_else(|| anyhow!("Configuration not found; run configuration list"))?; + ensure!( + entry.connection.is_some(), + "Configuration is invalid or unsupported; inspect its explanation" + ); + Ok(entry) + } + + async fn reconcile(&mut self) -> Result<()> { + let (enabled, selected, generation) = self.storage.state()?; + if enabled { + let entry = selected.map(|id| self.usable_entry(&id)).transpose()?; + if let Err(error) = self + .runtime + .apply( + entry.as_ref().and_then(|e| e.connection.as_ref()), + generation, + ) + .await + { + self.last_error = Some(error.to_string()); + return Err(error); + } + } + Ok(()) + } +} + +fn validate_name(name: &str) -> Result<()> { + ensure!( + !name.trim().is_empty() && name.len() <= 128 && !name.chars().any(char::is_control), + "Subscription name must be 1-128 bytes without control characters" + ); + Ok(()) +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..f8588ca --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,74 @@ +use crate::{ + bus::{NAME, PATH, Service}, + controller::Controller, + runtime::Runtime, + subscription::HttpSource, +}; +use anyhow::{Result, ensure}; +use std::{path::Path, sync::Arc, time::Duration}; +use tokio::sync::Mutex; + +pub fn runtime() -> Result<Box<dyn Runtime>> { + #[cfg(feature = "linux-runtime")] + { + Ok(Box::new(crate::linux::LinuxRuntime::default())) + } + #[cfg(not(feature = "linux-runtime"))] + { + anyhow::bail!("This build has no Linux runtime; rebuild with --features linux-runtime") + } +} + +pub async fn run() -> Result<()> { + ensure!( + unsafe { libc::geteuid() } == 0, + "The controller must run as a root-owned system service" + ); + let _lock = crate::locking::controller_lock()?; + let mut controller = Controller::open_paths( + Path::new("/etc/roentgen"), + Path::new("/var/lib/roentgen"), + runtime()?, + Box::new(HttpSource), + )?; + if let Err(error) = controller.restore().await { + controller.report_runtime_error(&error); + } + let controller = Arc::new(Mutex::new(controller)); + let _connection = zbus::connection::Builder::system()? + .name(NAME)? + .serve_at( + PATH, + Service { + controller: controller.clone(), + cancellation: Arc::new(tokio::sync::Notify::new()), + }, + )? + .build() + .await?; + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + _ = interval.tick() => { + let mut controller = controller.lock().await; + if let Err(error) = controller.scheduled_check().await { controller.report_runtime_error(&error); tracing::warn!("{error}"); } + } + } + } + // Exiting the controller never releases enabled protection or stops the core. + Ok(()) +} + +pub async fn guard() -> Result<()> { + ensure!(unsafe { libc::geteuid() } == 0, "The guard requires root"); + #[cfg(feature = "linux-runtime")] + { + crate::linux::restore_guard() + } + #[cfg(not(feature = "linux-runtime"))] + { + anyhow::bail!("Rebuild with --features linux-runtime") + } +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..a2052ec --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,172 @@ +use crate::model::Settings; +use anyhow::{Result, anyhow, ensure}; +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; +use tokio::io::AsyncReadExt; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Observation { + pub configuration_id: String, + pub generation: u64, + pub kind: String, + pub success: bool, + pub elapsed_ms: u64, + pub bytes: u64, + pub bytes_per_second: Option<f64>, + pub stop_reason: String, + pub retry_after_seconds: Option<u64>, +} + +/// The caller supplies an authenticated, service-owned SOCKS endpoint. This client +/// never inherits environment proxies or creates a direct route to the target. +pub async fn measure( + settings: &Settings, + proxy: &str, + id: String, + generation: u64, + speed: bool, +) -> Result<Observation> { + settings.validate()?; + let started = Instant::now(); + let duration = if speed { + settings.maximum_duration_seconds + } else { + 5 + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(duration); + let cap = if speed { + settings.maximum_download_mib * 1_048_576 + } else { + 4096 + }; + let proxy = + reqwest::Proxy::all(proxy).map_err(|_| anyhow!("Invalid protected probe endpoint"))?; + let client = reqwest::Client::builder() + .no_proxy() + .proxy(proxy) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(duration)) + .user_agent(concat!("Roentgen/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| anyhow!("Could not initialize diagnostic TLS client"))?; + let url = if speed { + &settings.speed_url + } else { + &settings.ping_url + }; + let response = tokio::time::timeout_at(deadline, client.get(url).send()) + .await + .map_err(|_| anyhow!("Diagnostic request reached its deadline"))? + .map_err(|_| anyhow!("Proxied HTTPS request failed"))?; + if response.status().as_u16() == 429 || response.status().as_u16() == 503 { + let retry = response + .headers() + .get("Retry-After") + .and_then(|v| v.to_str().ok()) + .and_then(|v| { + v.parse::<u64>().ok().or_else(|| { + httpdate::parse_http_date(v) + .ok() + .and_then(|date| date.duration_since(std::time::SystemTime::now()).ok()) + .map(|d| d.as_secs().saturating_add(1)) + }) + }) + .unwrap_or(30) + .max(30); + return Ok(Observation { + configuration_id: id, + generation, + kind: if speed { "speed" } else { "ping" }.into(), + success: false, + elapsed_ms: started.elapsed().as_millis() as u64, + bytes: 0, + bytes_per_second: None, + stop_reason: "provider-rate-limit".into(), + retry_after_seconds: Some(retry), + }); + } + ensure!( + response.status().as_u16() == 200 || !speed && response.status().as_u16() == 204, + "Diagnostic endpoint returned an unexpected HTTP status" + ); + let content_type = response + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + ensure!( + !content_type.contains("html"), + "Diagnostic endpoint returned HTML" + ); + let status = response.status().as_u16(); + let stream = response.bytes_stream().map_err(std::io::Error::other); + let mut reader = tokio_util::io::StreamReader::new(stream); + let mut buffer = [0u8; 16384]; + let mut prefix = Vec::new(); + let mut bytes = 0; + let reason; + loop { + if speed && bytes == cap { + reason = "byte-limit"; + break; + } + let remaining = if speed { + (cap - bytes).min(buffer.len() as u64) as usize + } else { + buffer.len().min((cap + 1 - bytes) as usize) + }; + let chunk = + match tokio::time::timeout_at(deadline, reader.read(&mut buffer[..remaining])).await { + Err(_) if speed && bytes > 0 => { + reason = "duration-limit"; + break; + } + Err(_) => return Err(anyhow!("Diagnostic response timed out")), + Ok(Err(_)) => { + return Err(anyhow!( + "Diagnostic response failed before clean completion" + )); + } + Ok(Ok(value)) => value, + }; + if chunk == 0 { + reason = "complete"; + break; + } + if prefix.len() < 512 { + prefix.extend_from_slice(&buffer[..chunk.min(512 - prefix.len())]); + } + bytes += chunk as u64; + if !speed { + ensure!(bytes <= cap, "Ping body exceeds 4 KiB"); + } + } + let prefix = String::from_utf8_lossy(&prefix) + .trim_start() + .to_ascii_lowercase(); + ensure!( + !prefix.starts_with("<!doctype html") && !prefix.starts_with("<html"), + "Diagnostic endpoint returned an HTML document" + ); + ensure!(!speed || bytes > 0, "Speed response contained no data"); + if !speed && settings.ping_url == Settings::default().ping_url { + ensure!( + bytes == 0 && status == 200, + "Default Ping must complete with HTTP 200 and an empty body" + ); + } + let elapsed = started.elapsed(); + Ok(Observation { + configuration_id: id, + generation, + kind: if speed { "speed" } else { "ping" }.into(), + success: true, + elapsed_ms: elapsed.as_millis() as u64, + bytes, + bytes_per_second: speed.then(|| bytes as f64 / elapsed.as_secs_f64().max(0.000_001)), + stop_reason: reason.into(), + retry_after_seconds: None, + }) +} diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..3207fb9 --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,26 @@ +use anyhow::{Context, Result, ensure}; +use std::{io::Write, process::Command}; + +pub fn open(lines: &[String]) -> Result<()> { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()); + let args = shell_words::split(&editor).context("Invalid quoting in EDITOR")?; + ensure!( + !args.is_empty(), + "EDITOR is empty; configure an editor command" + ); + let mut snapshot = tempfile::Builder::new() + .prefix("roentgen-logs-") + .suffix(".txt") + .tempfile()?; + for line in lines { + writeln!(snapshot, "{}", crate::model::display_text(line))?; + } + snapshot.flush()?; + let status = Command::new(&args[0]) + .args(&args[1..]) + .arg(snapshot.path()) + .status() + .context("Could not launch EDITOR")?; + ensure!(status.success(), "Editor exited unsuccessfully"); + Ok(()) +} diff --git a/src/installation.rs b/src/installation.rs new file mode 100644 index 0000000..bc86db6 --- /dev/null +++ b/src/installation.rs @@ -0,0 +1,152 @@ +use crate::{ + controller::Controller, + storage::{atomic_private_write, private_directory}, + subscription::HttpSource, + xray::systemctl, +}; +use anyhow::{Result, ensure}; +use std::{fs, path::Path, process::Stdio}; +use tokio::process::Command; + +const SERVICE: &str = include_str!("../assets/roentgen.service"); +const GUARD: &str = include_str!("../assets/roentgen-guard.service"); +const XRAY: &str = include_str!("../assets/roentgen-xray.service"); +const BUS: &str = include_str!("../assets/org.roentgen.Controller1.conf"); +const POLKIT: &str = include_str!("../assets/org.roentgen.policy"); + +fn root() -> Result<()> { + ensure!( + unsafe { libc::geteuid() } == 0, + "Run this explicit installation/recovery command as root" + ); + Ok(()) +} + +pub async fn install() -> Result<()> { + root()?; + let _ = crate::daemon::runtime()?; + crate::xray::preflight().await?; + private_directory(Path::new("/etc/roentgen"))?; + private_directory(Path::new("/var/lib/roentgen"))?; + if crate::xray::identity().is_err() { + let status = Command::new("/usr/sbin/useradd") + .args([ + "--system", + "--user-group", + "--no-create-home", + "--shell", + "/sbin/nologin", + "roentgen-xray", + ]) + .stdin(Stdio::null()) + .status() + .await?; + ensure!( + status.success(), + "Could not create the dedicated Xray account" + ); + } + let _ = systemctl(&["stop", "roentgen.service"]).await; + let _lock = crate::locking::controller_lock()?; + let mut controller = Controller::open_paths( + Path::new("/etc/roentgen"), + Path::new("/var/lib/roentgen"), + crate::daemon::runtime()?, + Box::new(HttpSource), + )?; + // Do not overwrite the saved guard or enabled intent on reinstallation. + #[cfg(feature = "linux-runtime")] + if !Path::new("/etc/roentgen/guard.toml").exists() { + crate::linux::write_guard(controller.status()?.requested_enabled)?; + } + let current = std::env::current_exe()?; + let target = Path::new("/usr/local/bin/roentgen"); + if current != target { + atomic_private_write(target, &fs::read(current)?)?; + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(target, fs::Permissions::from_mode(0o755))?; + } + for (path, content) in assets() { + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + } + systemctl(&["daemon-reload"]).await?; + // Ask dbus to reload policy without restarting the system bus. + let connection = zbus::Connection::system().await?; + zbus::fdo::DBusProxy::new(&connection) + .await? + .reload_config() + .await?; + systemctl(&["enable", "roentgen-guard.service", "roentgen.service"]).await?; + systemctl(&["start", "roentgen-guard.service"]).await?; + controller.restore().await?; + drop(controller); + drop(_lock); + systemctl(&["restart", "roentgen.service"]).await?; + Ok(()) +} + +fn assets() -> [(&'static str, &'static str); 8] { + [ + ("/etc/systemd/system/roentgen.service", SERVICE), + ("/etc/systemd/system/roentgen-guard.service", GUARD), + ("/etc/systemd/system/roentgen-xray.service", XRAY), + ("/etc/dbus-1/system.d/org.roentgen.Controller1.conf", BUS), + ("/usr/share/polkit-1/actions/org.roentgen.policy", POLKIT), + ( + "/etc/systemd/system/NetworkManager.service.d/50-roentgen-guard.conf", + "[Unit]\nRequires=roentgen-guard.service\nAfter=roentgen-guard.service\n", + ), + ( + "/etc/systemd/system/systemd-networkd.service.d/50-roentgen-guard.conf", + "[Unit]\nRequires=roentgen-guard.service\nAfter=roentgen-guard.service\n", + ), + ( + "/etc/systemd/system/network.service.d/50-roentgen-guard.conf", + "[Unit]\nRequires=roentgen-guard.service\nAfter=roentgen-guard.service\n", + ), + ] +} + +pub async fn recover() -> Result<()> { + root()?; + // Serialize recovery against ordinary controller transactions by stopping it first. + let _ = systemctl(&["stop", "roentgen.service"]).await; + let _lock = crate::locking::controller_lock()?; + crate::storage::persist_recovery_disabled(Path::new("/var/lib/roentgen"))?; + let mut runtime = crate::daemon::runtime()?; + runtime.disable().await +} + +pub async fn uninstall(purge: bool) -> Result<()> { + root()?; + recover().await?; + systemctl(&[ + "disable", + "--now", + "roentgen.service", + "roentgen-guard.service", + ]) + .await?; + #[cfg(feature = "linux-runtime")] + crate::linux::firewall::remove()?; + for (path, _) in assets() { + if Path::new(path).exists() { + fs::remove_file(path)?; + } + } + systemctl(&["daemon-reload"]).await?; + if Path::new("/run/roentgen").exists() { + fs::remove_dir_all("/run/roentgen")?; + } + if purge { + fs::remove_dir_all("/etc/roentgen")?; + fs::remove_dir_all("/var/lib/roentgen")?; + } + if Path::new("/usr/local/bin/roentgen").exists() { + fs::remove_file("/usr/local/bin/roentgen")?; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cdd9d34 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +pub mod authentication; +pub mod bus; +pub mod controller; +pub mod daemon; +pub mod diagnostics; +pub mod editor; +pub mod installation; +#[cfg(feature = "linux-runtime")] +pub mod linux; +mod locking; +pub mod model; +pub mod runtime; +mod storage; +pub mod subscription; +pub mod tui; +pub mod xray; diff --git a/src/linux/firewall.rs b/src/linux/firewall.rs new file mode 100644 index 0000000..79731b7 --- /dev/null +++ b/src/linux/firewall.rs @@ -0,0 +1,234 @@ +use anyhow::{Context, Result, ensure}; +use nftnl::{Batch, Chain, ChainType, Hook, MsgType, Policy, ProtoFamily, Rule, Table, nft_expr}; +use std::{ + collections::HashSet, + ffi::CString, + io, + net::{Ipv4Addr, Ipv6Addr}, + os::fd::AsRawFd, +}; + +pub const CAPTURE_MARK: u32 = 0x0200_0000; +const INCOMING_MARK: u32 = 0x0100_0000; +const TABLE: &std::ffi::CStr = c"roentgen"; + +fn socket() -> io::Result<mnl::Socket> { + let socket = mnl::Socket::new(mnl::Bus::Netfilter)?; + let timeout = libc::timeval { + tv_sec: 5, + tv_usec: 0, + }; + // SAFETY: the live socket fd and timeval pointer/length match SO_RCVTIMEO. + let result = unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + (&timeout as *const libc::timeval).cast(), + std::mem::size_of_val(&timeout) as _, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + Ok(socket) +} + +fn tables() -> Result<HashSet<CString>> { + let socket = socket()?; + socket.send(&nftnl::table::get_tables_nlmsg(1))?; + let mut tables = HashSet::new(); + let mut buffer = vec![0u8; nftnl::nft_nlmsg_maxsize() as usize]; + loop { + for message in socket.recv(&mut buffer)? { + if matches!( + mnl::cb_run2( + message?, + 1, + socket.portid(), + nftnl::table::get_tables_cb, + &mut tables + )?, + mnl::CbResult::Stop + ) { + return Ok(tables); + } + } + } +} + +fn send(batch: nftnl::FinalizedBatch) -> Result<()> { + let socket = socket()?; + socket.send_all(&batch)?; + let mut buffer = vec![0u8; nftnl::nft_nlmsg_maxsize() as usize]; + let mut sequences = batch.sequence_numbers(); + while !sequences.is_empty() { + for message in socket.recv(&mut buffer)? { + let sequence = sequences + .next() + .context("Unexpected firewall acknowledgement")?; + mnl::cb_run(message?, sequence, socket.portid())?; + } + } + Ok(()) +} + +pub fn preflight() -> Result<()> { + let tables = tables()?; + ensure!( + tables.iter().all(|name| name.as_c_str() == TABLE), + "Other nftables tables exist; this firewall arrangement is not supported. Roentgen did not alter them" + ); + Ok(()) +} + +/// Replacing the table is one kernel transaction. Connection marks survive table replacement. +pub fn apply(enabled: bool, xray_uid: u32) -> Result<()> { + let existing = tables()?; + let table = Table::new(TABLE, ProtoFamily::Inet); + let mut batch = Batch::new(); + if existing.contains(TABLE) { + batch.add(&table, MsgType::Del); + } + batch.add(&table, MsgType::Add); + let mut incoming = Chain::new(c"incoming", &table); + incoming.set_type(ChainType::Filter); + incoming.set_hook(Hook::In, -150); + incoming.set_policy(Policy::Accept); + batch.add(&incoming, MsgType::Add); + let mut classify = Rule::new(&incoming); + classify.add_expr(&nft_expr!(meta iifname)); + classify.add_expr(&nft_expr!(cmp != &b"lo\0"[..])); + classify.add_expr(&nft_expr!(ct state)); + classify.add_expr(&nft_expr!(bitwise mask 8u32, xor 0u32)); + classify.add_expr(&nft_expr!(cmp == 8u32)); + classify.add_expr(&nft_expr!(ct mark)); + classify.add_expr(&nft_expr!(bitwise mask !INCOMING_MARK, xor INCOMING_MARK)); + classify.add_expr(&nft_expr!(ct mark set)); + batch.add(&classify, MsgType::Add); + if enabled { + let mut routing = Chain::new(c"route", &table); + routing.set_type(ChainType::Route); + routing.set_hook(Hook::Out, -150); + routing.set_policy(Policy::Accept); + batch.add(&routing, MsgType::Add); + exceptions(&mut batch, &routing, xray_uid); + let mut mark = Rule::new(&routing); + mark.add_expr(&nft_expr!(meta mark)); + mark.add_expr(&nft_expr!(bitwise mask !CAPTURE_MARK, xor CAPTURE_MARK)); + mark.add_expr(&nft_expr!(meta mark set)); + batch.add(&mark, MsgType::Add); + let mut guard = Chain::new(c"guard", &table); + guard.set_type(ChainType::Filter); + guard.set_hook(Hook::Out, 100); + guard.set_policy(Policy::Drop); + batch.add(&guard, MsgType::Add); + exceptions(&mut batch, &guard, xray_uid); + let mut tun = Rule::new(&guard); + tun.add_expr(&nft_expr!(meta oifname)); + tun.add_expr(&nft_expr!(cmp == &b"roentgen0\0"[..])); + tun.add_expr(&nft_expr!(verdict accept)); + batch.add(&tun, MsgType::Add); + } + send(batch.finalize()).context("Could not apply Roentgen firewall transaction") +} + +fn exceptions(batch: &mut Batch, chain: &Chain<'_>, xray_uid: u32) { + let mut loopback = Rule::new(chain); + loopback.add_expr(&nft_expr!(meta oifname)); + loopback.add_expr(&nft_expr!(cmp == &b"lo\0"[..])); + loopback.add_expr(&nft_expr!(verdict accept)); + batch.add(&loopback, MsgType::Add); + let mut core = Rule::new(chain); + core.add_expr(&nft_expr!(meta skuid)); + core.add_expr(&nft_expr!(cmp == xray_uid)); + core.add_expr(&nft_expr!(verdict accept)); + batch.add(&core, MsgType::Add); + let mut replies = Rule::new(chain); + replies.add_expr(&nft_expr!(ct mark)); + replies.add_expr(&nft_expr!(bitwise mask INCOMING_MARK, xor 0u32)); + replies.add_expr(&nft_expr!(cmp == INCOMING_MARK)); + replies.add_expr(&nft_expr!(verdict accept)); + batch.add(&replies, MsgType::Add); + for (network, mask) in [ + (Ipv4Addr::new(10, 0, 0, 0), Ipv4Addr::new(255, 0, 0, 0)), + (Ipv4Addr::new(172, 16, 0, 0), Ipv4Addr::new(255, 240, 0, 0)), + (Ipv4Addr::new(192, 168, 0, 0), Ipv4Addr::new(255, 255, 0, 0)), + ] { + let mut rule = Rule::new(chain); + rule.add_expr(&nft_expr!(meta nfproto)); + rule.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV4 as u8)); + rule.add_expr(&nft_expr!(payload ipv4 daddr)); + rule.add_expr(&nft_expr!(bitwise mask mask, xor 0u32)); + rule.add_expr(&nft_expr!(cmp == network)); + rule.add_expr(&nft_expr!(verdict accept)); + batch.add(&rule, MsgType::Add); + } + for (network, mask) in [( + Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), + Ipv6Addr::new(0xfe00, 0, 0, 0, 0, 0, 0, 0), + )] { + let mut rule = Rule::new(chain); + rule.add_expr(&nft_expr!(meta nfproto)); + rule.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV6 as u8)); + rule.add_expr(&nft_expr!(payload ipv6 daddr)); + rule.add_expr(&nft_expr!(bitwise mask mask, xor Ipv6Addr::UNSPECIFIED)); + rule.add_expr(&nft_expr!(cmp == network)); + rule.add_expr(&nft_expr!(verdict accept)); + batch.add(&rule, MsgType::Add); + } + // DHCPv4 broadcast is link control, not a general broadcast exception. + let mut dhcp = Rule::new(chain); + dhcp.add_expr(&nft_expr!(meta nfproto)); + dhcp.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV4 as u8)); + dhcp.add_expr(&nft_expr!(payload ipv4 daddr)); + dhcp.add_expr(&nft_expr!(cmp == Ipv4Addr::BROADCAST)); + dhcp.add_expr(&nft_expr!(meta l4proto)); + dhcp.add_expr(&nft_expr!(cmp == libc::IPPROTO_UDP as u8)); + dhcp.add_expr(&nft_expr!(payload udp sport)); + dhcp.add_expr(&nft_expr!(cmp == 68u16.to_be())); + dhcp.add_expr(&nft_expr!(payload udp dport)); + dhcp.add_expr(&nft_expr!(cmp == 67u16.to_be())); + dhcp.add_expr(&nft_expr!(verdict accept)); + batch.add(&dhcp, MsgType::Add); + let mut dhcp6 = Rule::new(chain); + dhcp6.add_expr(&nft_expr!(meta nfproto)); + dhcp6.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV6 as u8)); + dhcp6.add_expr(&nft_expr!(payload ipv6 daddr)); + dhcp6.add_expr(&nft_expr!( + cmp == Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 1, 2) + )); + dhcp6.add_expr(&nft_expr!(meta l4proto)); + dhcp6.add_expr(&nft_expr!(cmp == libc::IPPROTO_UDP as u8)); + dhcp6.add_expr(&nft_expr!(payload udp sport)); + dhcp6.add_expr(&nft_expr!(cmp == 546u16.to_be())); + dhcp6.add_expr(&nft_expr!(payload udp dport)); + dhcp6.add_expr(&nft_expr!(cmp == 547u16.to_be())); + dhcp6.add_expr(&nft_expr!(verdict accept)); + batch.add(&dhcp6, MsgType::Add); + // Limit multicast ICMPv6 to router/neighbor discovery with hop limit 255. + for kind in [133u8, 134, 135, 136] { + let mut rule = Rule::new(chain); + rule.add_expr(&nft_expr!(meta nfproto)); + rule.add_expr(&nft_expr!(cmp == libc::NFPROTO_IPV6 as u8)); + rule.add_expr(&nft_expr!(meta l4proto)); + rule.add_expr(&nft_expr!(cmp == libc::IPPROTO_ICMPV6 as u8)); + rule.add_expr(&nft_expr!(payload ipv6 hoplimit)); + rule.add_expr(&nft_expr!(cmp == 255u8)); + rule.add_expr(&nftnl::expr::Payload::Transport( + nftnl::expr::TransportHeaderField::Icmpv6(nftnl::expr::Icmpv6HeaderField::Type), + )); + rule.add_expr(&nft_expr!(cmp == kind)); + rule.add_expr(&nft_expr!(verdict accept)); + batch.add(&rule, MsgType::Add); + } +} + +pub fn remove() -> Result<()> { + if tables()?.contains(TABLE) { + let mut batch = Batch::new(); + batch.add(&Table::new(TABLE, ProtoFamily::Inet), MsgType::Del); + send(batch.finalize())?; + } + Ok(()) +} diff --git a/src/linux/mod.rs b/src/linux/mod.rs new file mode 100644 index 0000000..68bb3a8 --- /dev/null +++ b/src/linux/mod.rs @@ -0,0 +1,119 @@ +pub mod firewall; +pub mod routes; + +use crate::{model::Trojan, runtime::Runtime, xray}; +use anyhow::Result; +use async_trait::async_trait; +use std::{path::Path, time::Duration}; + +pub struct LinuxRuntime { + routing: String, + applied: Option<Trojan>, +} +impl Default for LinuxRuntime { + fn default() -> Self { + Self { + routing: "unknown".into(), + applied: None, + } + } +} + +#[async_trait] +impl Runtime for LinuxRuntime { + async fn arm(&mut self) -> Result<()> { + xray::preflight().await?; + firewall::preflight()?; + routes::preflight().await?; + let (uid, _) = xray::identity()?; + firewall::apply(true, uid)?; + // Persist the boot guard input before recording enabled intent in SQLite. + write_guard(true)?; + self.routing = "blocked".into(); + Ok(()) + } + async fn apply(&mut self, connection: Option<&Trojan>, _: u64) -> Result<()> { + if self.applied.as_ref() == connection { + if let Some(index) = routes::tun_index().await? { + routes::capture(index).await?; + self.routing = "tun".into(); + return Ok(()); + } + } + self.routing = "blocked".into(); + self.applied = None; + xray::systemctl(&["stop", "roentgen-xray.service"]).await?; + routes::remove().await?; + if let Some(connection) = connection { + xray::write_config( + Path::new("/run/roentgen/xray.json"), + &xray::configuration(connection, true, None), + )?; + xray::systemctl(&["start", "roentgen-xray.service"]).await?; + for _ in 0..100 { + if let Some(index) = routes::tun_index().await? { + routes::capture(index).await?; + self.routing = "tun".into(); + self.applied = Some(connection.clone()); + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + anyhow::bail!("Xray did not create roentgen0; traffic remains blocked"); + } + Ok(()) + } + async fn maintain(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()> { + match routes::tun_index().await? { + Some(index) if connection.is_some() => { + routes::capture(index).await?; + self.routing = "tun".into(); + Ok(()) + } + _ => self.apply(connection, generation).await, + } + } + async fn disable(&mut self) -> Result<()> { + xray::systemctl(&["stop", "roentgen-xray.service"]).await?; + routes::remove().await?; + self.applied = None; + write_guard(false)?; + firewall::apply(false, xray::identity()?.0)?; + self.routing = "direct".into(); + Ok(()) + } + fn routing(&self) -> &str { + &self.routing + } +} + +pub fn write_guard(enabled: bool) -> Result<()> { + crate::storage::atomic_private_write( + Path::new("/etc/roentgen/guard.toml"), + format!("version = 1\nenabled = {enabled}\n").as_bytes(), + ) +} + +pub fn restore_guard() -> Result<()> { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Guard { + version: u32, + enabled: bool, + } + let uid = xray::identity()?.0; + let input = std::fs::read_to_string("/etc/roentgen/guard.toml"); + let value = input + .ok() + .and_then(|input| toml::from_str::<Guard>(&input).ok()) + .filter(|value| value.version == 1); + match value { + Some(value) => firewall::apply(value.enabled, uid), + None => { + firewall::apply(true, uid)?; + anyhow::bail!( + "Corrupt or missing guard state; protection was armed. Use explicit recover --disable" + ); + } + } +} diff --git a/src/linux/routes.rs b/src/linux/routes.rs new file mode 100644 index 0000000..07083bb --- /dev/null +++ b/src/linux/routes.rs @@ -0,0 +1,186 @@ +use super::firewall::CAPTURE_MARK; +use anyhow::{Result, ensure}; +use futures_util::TryStreamExt; +use rtnetlink::{ + Handle, IpVersion, RouteMessageBuilder, + packet_route::{ + route::{RouteAttribute, RouteProtocol}, + rule::{RuleAction, RuleAttribute}, + }, +}; +use std::net::{Ipv4Addr, Ipv6Addr}; + +const TABLE_ID: u32 = 20260; +const PRIORITY: u32 = 10260; +const PROTOCOL: u8 = 186; + +fn connection() -> Result<Handle> { + let (connection, handle, _) = rtnetlink::new_connection()?; + tokio::spawn(connection); + Ok(handle) +} + +pub async fn preflight() -> Result<()> { + let handle = connection()?; + for version in [IpVersion::V4, IpVersion::V6] { + let mut rules = handle.rule().get(version).execute(); + while let Some(rule) = rules.try_next().await? { + let priority = rule + .attributes + .iter() + .find_map(|a| { + if let RuleAttribute::Priority(v) = a { + Some(*v) + } else { + None + } + }) + .unwrap_or(0); + ensure!( + matches!(priority, 0 | 32766 | 32767) || owned_rule(&rule), + "Unsupported policy-routing rule at priority {}; remove conflicting VPN or policy routing before enabling", + priority + ); + } + } + let mut routes = handle + .route() + .get(rtnetlink::RouteMessageBuilder::<std::net::IpAddr>::new().build()) + .execute(); + while let Some(route) = routes.try_next().await? { + if table(&route) == TABLE_ID { + ensure!( + route.header.protocol == RouteProtocol::Other(PROTOCOL), + "Roentgen route table is already in use" + ); + } + } + Ok(()) +} + +fn table(route: &rtnetlink::packet_route::route::RouteMessage) -> u32 { + route + .attributes + .iter() + .find_map(|a| { + if let RouteAttribute::Table(v) = a { + Some(*v) + } else { + None + } + }) + .unwrap_or(u32::from(route.header.table)) +} +fn owned_rule(rule: &rtnetlink::packet_route::rule::RuleMessage) -> bool { + rule.attributes.contains(&RuleAttribute::Priority(PRIORITY)) + && rule + .attributes + .contains(&RuleAttribute::FwMark(CAPTURE_MARK)) + && rule + .attributes + .contains(&RuleAttribute::FwMask(CAPTURE_MARK)) + && rule.attributes.contains(&RuleAttribute::Table(TABLE_ID)) + && rule.header.action == RuleAction::ToTable +} + +pub async fn capture(interface: u32) -> Result<()> { + let handle = connection()?; + for route in [ + RouteMessageBuilder::<Ipv4Addr>::new() + .table_id(TABLE_ID) + .output_interface(interface) + .protocol(RouteProtocol::Other(PROTOCOL)) + .build(), + RouteMessageBuilder::<Ipv6Addr>::new() + .table_id(TABLE_ID) + .output_interface(interface) + .protocol(RouteProtocol::Other(PROTOCOL)) + .build(), + ] { + handle.route().add(route).replace().execute().await?; + } + for version in [IpVersion::V4, IpVersion::V6] { + let existing: Vec<_> = handle + .rule() + .get(version.clone()) + .execute() + .try_collect() + .await?; + if existing.iter().any(owned_rule) { + continue; + } + match version { + IpVersion::V4 => { + let mut request = handle + .rule() + .add() + .v4() + .priority(PRIORITY) + .table_id(TABLE_ID) + .fw_mark(CAPTURE_MARK) + .action(RuleAction::ToTable); + request + .message_mut() + .attributes + .push(RuleAttribute::FwMask(CAPTURE_MARK)); + request.execute().await?; + } + IpVersion::V6 => { + let mut request = handle + .rule() + .add() + .v6() + .priority(PRIORITY) + .table_id(TABLE_ID) + .fw_mark(CAPTURE_MARK) + .action(RuleAction::ToTable); + request + .message_mut() + .attributes + .push(RuleAttribute::FwMask(CAPTURE_MARK)); + request.execute().await?; + } + } + } + Ok(()) +} + +pub async fn remove() -> Result<()> { + let handle = connection()?; + for version in [IpVersion::V4, IpVersion::V6] { + let rules: Vec<_> = handle + .rule() + .get(version.clone()) + .execute() + .try_collect() + .await?; + for rule in rules.into_iter().filter(owned_rule) { + handle.rule().del(rule).execute().await?; + } + } + let routes: Vec<_> = handle + .route() + .get(RouteMessageBuilder::<std::net::IpAddr>::new().build()) + .execute() + .try_collect() + .await?; + for route in routes + .into_iter() + .filter(|r| table(r) == TABLE_ID && r.header.protocol == RouteProtocol::Other(PROTOCOL)) + { + handle.route().del(route).execute().await?; + } + Ok(()) +} + +pub async fn tun_index() -> Result<Option<u32>> { + let handle = connection()?; + Ok(handle + .link() + .get() + .match_name("roentgen0") + .execute() + .try_next() + .await? + .map(|link| link.header.index)) +} diff --git a/src/locking.rs b/src/locking.rs new file mode 100644 index 0000000..1c39471 --- /dev/null +++ b/src/locking.rs @@ -0,0 +1,35 @@ +use anyhow::{Context, Result, ensure}; +use std::{ + fs::{File, OpenOptions}, + os::fd::AsRawFd, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::Path, +}; + +pub fn controller_lock() -> Result<File> { + let directory = Path::new("/run/roentgen"); + std::fs::create_dir_all(directory)?; + let metadata = std::fs::symlink_metadata(directory)?; + ensure!( + metadata.is_dir() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o022 == 0, + "Runtime directory must be service-owned and not writable by other users" + ); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(directory.join("controller.lock")) + .context("Open controller lock")?; + // SAFETY: file owns this live fd; flock does not retain a pointer. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + ensure!( + result == 0, + "Another controller is running; stop it before explicit recovery" + ); + Ok(file) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..7ad2d81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,243 @@ -fn main() { - println!("Hello, world!"); +use anyhow::{Result, ensure}; +use clap::{Parser, Subcommand}; +use roentgen::{ + bus::Client, + controller::Operation, + model::{Status, SubscriptionSummary}, +}; +use std::io::{self, BufRead, IsTerminal, Read, Write}; + +#[derive(Parser)] +#[command( + version, + about = "Control local-machine proxying through the Roentgen service" +)] +struct Cli { + #[command(subcommand)] + command: Option<Command>, +} + +#[derive(Subcommand)] +enum Command { + /// Open the terminal interface. + Tui, + /// Read sanitized requested state, routing, and proxy health. + Status { + #[arg(long)] + json: bool, + }, + #[command(subcommand)] + Subscription(SubscriptionCommand), + #[command(subcommand)] + Configuration(ConfigurationCommand), + Enable, + Disable, + /// Measure HTTPS delay through a configuration without enabling host proxying. + Ping { + id: Option<String>, + }, + /// Run a bounded manual download through a configuration. + Speed { + id: Option<String>, + }, + Logs { + #[arg(long, required = true)] + editor: bool, + }, + /// Install systemwide service assets. Requires root. + Install, + Uninstall { + #[arg(long)] + purge: bool, + }, + /// Explicitly persist disabled intent and release only owned networking. + Recover { + #[arg(long, required = true)] + disable: bool, + }, + #[command(hide = true)] + Daemon, + #[command(hide = true)] + Guard, +} + +#[derive(Subcommand)] +enum SubscriptionCommand { + /// Read the secret URL from a prompt or one stdin line. + Add { + #[arg(long)] + name: Option<String>, + }, + List, + Edit { + name: String, + }, + Delete { + name: String, + }, + Update { + #[arg(required_unless_present = "all", conflicts_with = "all")] + name: Option<String>, + #[arg(long)] + all: bool, + }, +} +#[derive(Subcommand)] +enum ConfigurationCommand { + List { + #[arg(long)] + subscription: Option<String>, + }, + Select { + id: String, + }, + Unset, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + if let Err(error) = run(Cli::parse()).await { + eprintln!("roentgen: {error}"); + std::process::exit(1); + } +} + +async fn run(cli: Cli) -> Result<()> { + let command = cli.command.unwrap_or(Command::Tui); + match command { + Command::Install => return roentgen::installation::install().await, + Command::Uninstall { purge } => return roentgen::installation::uninstall(purge).await, + Command::Recover { .. } => return roentgen::installation::recover().await, + Command::Daemon => return roentgen::daemon::run().await, + Command::Guard => return roentgen::daemon::guard().await, + _ => {} + } + let client = Client::system().await?; + let interactive = io::stdin().is_terminal() && io::stderr().is_terminal(); + let mutation = matches!( + command, + Command::Enable + | Command::Disable + | Command::Ping { .. } + | Command::Speed { .. } + | Command::Subscription( + SubscriptionCommand::Add { .. } + | SubscriptionCommand::Edit { .. } + | SubscriptionCommand::Delete { .. } + | SubscriptionCommand::Update { .. } + ) + | Command::Configuration( + ConfigurationCommand::Select { .. } | ConfigurationCommand::Unset + ) + ); + let _agent = roentgen::authentication::TerminalAgent::start(interactive && mutation)?; + match command { + Command::Tui => roentgen::tui::run(client).await?, + Command::Status { json } => { + let status: Status = client.read("Status").await?; + if json { + println!("{}", serde_json::to_string(&status)?); + } else { + println!( + "Requested: {}\nSelected: {}\nRouting: {}\nHealth: {}", + if status.requested_enabled { + "enabled" + } else { + "disabled" + }, + status.selected_configuration.as_deref().unwrap_or("unset"), + status.routing, + status.health + ); + if let Some(error) = status.last_error { + println!("Error: {error}"); + } + } + } + Command::Subscription(SubscriptionCommand::List) => { + for subscription in client + .read::<Vec<SubscriptionSummary>>("Subscriptions") + .await? + { + println!("{}\t{}", subscription.id, subscription.name); + } + } + Command::Subscription(SubscriptionCommand::Add { name }) => { + let name = name.unwrap_or_else(|| { + format!("Subscription-{}", &uuid::Uuid::new_v4().to_string()[..8]) + }); + let url = secret_line("Subscription URL: ")?; + client + .change(Operation::AddSubscription { name, url }, interactive) + .await?; + } + Command::Subscription(SubscriptionCommand::Edit { name }) => { + let url = secret_line("New subscription URL: ")?; + client + .change(Operation::EditSubscription { name, url }, interactive) + .await?; + } + Command::Subscription(SubscriptionCommand::Delete { name }) => { + client + .change(Operation::DeleteSubscription { name }, interactive) + .await? + } + Command::Subscription(SubscriptionCommand::Update { name, .. }) => { + client + .change(Operation::Refresh { name }, interactive) + .await? + } + Command::Configuration(ConfigurationCommand::List { subscription }) => { + for entry in client.configurations(subscription.as_deref()).await? { + println!( + "{}\t{}\t{}\t{}\t{}", + if entry.selected { "*" } else { " " }, + entry.id, + entry.name, + entry.status, + entry.explanation.as_deref().unwrap_or("") + ); + } + } + Command::Configuration(ConfigurationCommand::Select { id }) => { + client.change(Operation::Select { id }, interactive).await? + } + Command::Configuration(ConfigurationCommand::Unset) => { + client.change(Operation::Unset, interactive).await? + } + Command::Enable => client.change(Operation::Enable, interactive).await?, + Command::Disable => client.change(Operation::Disable, interactive).await?, + Command::Ping { id } => println!( + "{}", + client.diagnostic(id.as_deref(), false, interactive).await? + ), + Command::Speed { id } => println!( + "{}", + client.diagnostic(id.as_deref(), true, interactive).await? + ), + Command::Logs { .. } => roentgen::editor::open(&client.read::<Vec<String>>("Logs").await?)?, + _ => unreachable!(), + } + Ok(()) +} + +fn secret_line(prompt: &str) -> Result<String> { + if io::stdin().is_terminal() { + eprint!("{prompt}"); + io::stderr().flush()?; + } + let mut line = String::new(); + let count = io::stdin() + .lock() + .take(16 * 1024 + 1) + .read_line(&mut line)?; + ensure!( + count > 0 && count <= 16 * 1024, + "Provide one subscription URL on stdin, up to 16 KiB" + ); + Ok(line.trim_end_matches(['\r', '\n']).to_owned()) } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..8966956 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,147 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Trojan { + pub host: String, + pub port: u16, + pub password: String, + pub server_name: String, + pub alpn: Vec<String>, + pub fingerprint: Option<String>, +} + +impl Trojan { + pub fn identity(&self) -> (&str, u16, &str) { + (&self.host, self.port, &self.password) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Entry { + pub id: String, + pub name: String, + pub status: String, + pub explanation: Option<String>, + pub connection: Option<Trojan>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ConfigurationSummary { + pub id: String, + pub subscription_id: String, + pub name: String, + pub host: Option<String>, + pub port: Option<u16>, + pub status: String, + pub explanation: Option<String>, + pub selected: bool, + pub ping_ms: Option<u64>, + pub speed_bytes_per_second: Option<f64>, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Subscription { + pub id: String, + pub name: String, + pub url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SubscriptionSummary { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub ping_url: String, + pub speed_url: String, + pub maximum_download_mib: u64, + pub maximum_duration_seconds: u64, +} + +impl Default for Settings { + fn default() -> Self { + Self { + ping_url: "https://speed.cloudflare.com/__down?bytes=0".into(), + speed_url: "https://speed.cloudflare.com/__down?bytes=10485760".into(), + maximum_download_mib: 10, + maximum_duration_seconds: 10, + } + } +} + +impl Settings { + pub fn validate(&self) -> anyhow::Result<()> { + for value in [&self.ping_url, &self.speed_url] { + let url = url::Url::parse(value) + .map_err(|_| anyhow::anyhow!("Use an absolute HTTPS diagnostic URL"))?; + anyhow::ensure!( + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none(), + "Diagnostic URLs require HTTPS without credentials or fragments" + ); + } + anyhow::ensure!( + self.maximum_download_mib > 0 + && self.maximum_download_mib.checked_mul(1_048_576).is_some(), + "Download limit must be a positive representable MiB count" + ); + anyhow::ensure!( + self.maximum_duration_seconds > 0, + "Duration limit must be positive" + ); + anyhow::ensure!( + std::time::Instant::now() + .checked_add(std::time::Duration::from_secs( + self.maximum_duration_seconds + )) + .is_some(), + "Duration exceeds the supported clock range" + ); + Ok(()) + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Definitions { + pub version: u32, + #[serde(default)] + pub settings: Settings, + #[serde(default)] + pub subscriptions: Vec<Subscription>, +} + +impl Default for Definitions { + fn default() -> Self { + Self { + version: 1, + settings: Settings::default(), + subscriptions: vec![], + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Status { + pub version: u32, + pub requested_enabled: bool, + pub selected_configuration: Option<String>, + pub generation: u64, + pub routing: String, + pub health: String, + pub last_error: Option<String>, +} + +pub fn display_text(value: &str) -> String { + value + .chars() + .filter(|c| !c.is_control()) + .take(256) + .collect() +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..96ffd0d --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,34 @@ +use crate::model::Trojan; +use anyhow::{Result, bail}; +use async_trait::async_trait; + +#[async_trait] +pub trait Runtime: Send { + /// Protection must be installed before durable enabled intent changes. + async fn arm(&mut self) -> Result<()>; + async fn apply(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()>; + async fn maintain(&mut self, connection: Option<&Trojan>, generation: u64) -> Result<()> { + self.apply(connection, generation).await + } + /// Called only after disabled intent has been durably recorded. + async fn disable(&mut self) -> Result<()>; + fn routing(&self) -> &str; +} + +/// Allows managing saved subscriptions without claiming that host capture is available. +pub struct OfflineRuntime; +#[async_trait] +impl Runtime for OfflineRuntime { + async fn arm(&mut self) -> Result<()> { + bail!("Host networking is unavailable; install and start the Roentgen service") + } + async fn apply(&mut self, _: Option<&Trojan>, _: u64) -> Result<()> { + bail!("Host networking is unavailable") + } + async fn disable(&mut self) -> Result<()> { + Ok(()) + } + fn routing(&self) -> &str { + "unavailable" + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..cb7fcd9 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,264 @@ +use crate::model::{Definitions, Entry}; +use anyhow::{Context, Result, ensure}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::{ + fs, + io::Write, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::{Path, PathBuf}, +}; + +pub struct Storage { + db: Connection, + pub definitions: Definitions, + config: PathBuf, +} + +pub fn private_directory(path: &Path) -> Result<()> { + fs::create_dir_all(path).context("Create Roentgen directory")?; + let metadata = fs::symlink_metadata(path)?; + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "Roentgen directory must not be a symlink" + ); + ensure!( + metadata.uid() == unsafe { libc::geteuid() }, + "Roentgen directory must belong to the service user" + ); + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +pub fn atomic_private_write(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().context("Missing parent directory")?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(data)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|e| e.error)?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} + +impl Storage { + pub fn open(config_dir: &Path, data_dir: &Path) -> Result<Self> { + private_directory(config_dir)?; + private_directory(data_dir)?; + let config = config_dir.join("config.toml"); + let definitions: Definitions = if config.exists() { + ensure!( + fs::symlink_metadata(&config)?.is_file(), + "Editable definitions must be a regular file" + ); + toml::from_str(&fs::read_to_string(&config)?) + .context("Invalid Roentgen TOML; repair it before starting")? + } else { + Definitions::default() + }; + ensure!(definitions.version == 1, "Unsupported TOML version"); + definitions.settings.validate()?; + let mut ids = std::collections::HashSet::new(); + let mut names = std::collections::HashSet::new(); + for subscription in &definitions.subscriptions { + ensure!( + !subscription.id.is_empty() + && ids.insert(subscription.id.clone()) + && !subscription.name.is_empty() + && names.insert(subscription.name.clone()), + "Subscription IDs and names must be unique and nonempty" + ); + crate::subscription::subscription_url(&subscription.url)?; + } + let path = data_dir.join("state.sqlite3"); + if path.exists() { + ensure!( + fs::symlink_metadata(&path)?.is_file(), + "State database must be a regular file" + ); + } + let db = Connection::open(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + db.busy_timeout(std::time::Duration::from_secs(5))?; + db.execute_batch("PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;")?; + let version: u32 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; + ensure!(version <= 1, "Unsupported database version"); + if version == 0 { + db.execute_batch("BEGIN IMMEDIATE; + CREATE TABLE entries (subscription_id TEXT NOT NULL, ordinal INTEGER NOT NULL, id TEXT PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), selected TEXT, generation INTEGER NOT NULL); + INSERT INTO state VALUES (1,0,NULL,0); + PRAGMA user_version=1; COMMIT;")?; + } + let store = Self { + db, + definitions, + config, + }; + // Removed TOML definitions never authorize retained snapshots or a stale selection. + let known: std::collections::HashSet<_> = store + .definitions + .subscriptions + .iter() + .map(|s| s.id.as_str()) + .collect(); + for subscription_id in store + .db + .prepare("SELECT DISTINCT subscription_id FROM entries")? + .query_map([], |r| r.get::<_, String>(0))? + .collect::<rusqlite::Result<Vec<_>>>()? + { + if !known.contains(subscription_id.as_str()) { + store.delete_entries(&subscription_id)?; + } + } + if !store.config.exists() { + store.save_definitions(&store.definitions)?; + } + Ok(store) + } + + pub fn save_definitions(&self, definitions: &Definitions) -> Result<()> { + atomic_private_write( + &self.config, + toml::to_string_pretty(definitions)?.as_bytes(), + ) + } + + pub fn state(&self) -> Result<(bool, Option<String>, u64)> { + Ok(self.db.query_row( + "SELECT enabled,selected,generation FROM state WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? as u64)), + )?) + } + + pub fn set_intent(&self, enabled: bool) -> Result<()> { + self.db.execute( + "UPDATE state SET enabled=?1,generation=generation+1 WHERE singleton=1", + [enabled], + )?; + Ok(()) + } + + pub fn select(&self, id: Option<&str>) -> Result<()> { + self.db.execute( + "UPDATE state SET selected=?1,generation=generation+1 WHERE singleton=1", + [id], + )?; + Ok(()) + } + + pub fn entries(&self, subscription_id: &str) -> Result<Vec<Entry>> { + let mut query = self + .db + .prepare("SELECT data FROM entries WHERE subscription_id=?1 ORDER BY ordinal")?; + let values = query + .query_map([subscription_id], |r| r.get::<_, String>(0))? + .collect::<rusqlite::Result<Vec<_>>>()?; + values + .iter() + .map(|v| { + serde_json::from_str(v) + .context("Corrupt saved configuration; explicit recovery is required") + }) + .collect() + } + + pub fn find_entry(&self, id: &str) -> Result<Option<Entry>> { + self.db + .query_row("SELECT data FROM entries WHERE id=?1", [id], |r| { + r.get::<_, String>(0) + }) + .optional()? + .map(|v| serde_json::from_str(&v).context("Corrupt saved configuration")) + .transpose() + } + + pub fn replace_entries(&mut self, subscription_id: &str, entries: &[Entry]) -> Result<()> { + let transaction = self.db.transaction()?; + let selected: Option<String> = + transaction.query_row("SELECT selected FROM state WHERE singleton=1", [], |r| { + r.get(0) + })?; + let owns_selection = match selected.as_ref() { + Some(id) => contains_id(&transaction, subscription_id, id)?, + None => false, + }; + transaction.execute( + "DELETE FROM entries WHERE subscription_id=?1", + [subscription_id], + )?; + for (ordinal, entry) in entries.iter().enumerate() { + transaction.execute( + "INSERT INTO entries VALUES (?1,?2,?3,?4)", + params![ + subscription_id, + ordinal as i64, + entry.id, + serde_json::to_string(entry)? + ], + )?; + } + if owns_selection + && !entries + .iter() + .any(|e| Some(&e.id) == selected.as_ref() && e.connection.is_some()) + { + transaction.execute("UPDATE state SET selected=NULL WHERE singleton=1", [])?; + } + transaction.execute( + "UPDATE state SET generation=generation+1 WHERE singleton=1", + [], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn delete_entries(&self, subscription_id: &str) -> Result<()> { + self.db.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<()> { + self.db.execute("UPDATE state SET selected=NULL,generation=generation+1 WHERE singleton=1 AND selected IN (SELECT id FROM entries WHERE subscription_id=?1)", [subscription_id])?; + self.db.execute( + "DELETE FROM entries WHERE subscription_id=?1", + [subscription_id], + )?; + self.db.execute_batch("COMMIT")?; + Ok(()) + })(); + if result.is_err() { + let _ = self.db.execute_batch("ROLLBACK"); + } + result + } +} + +fn contains_id(db: &Connection, subscription_id: &str, id: &str) -> Result<bool> { + Ok(db.query_row( + "SELECT EXISTS(SELECT 1 FROM entries WHERE subscription_id=?1 AND id=?2)", + params![subscription_id, id], + |r| r.get(0), + )?) +} + +/// Recovery deliberately needs no valid editable definitions or imported entries. +pub fn persist_recovery_disabled(data_dir: &Path) -> Result<()> { + private_directory(data_dir)?; + let path = data_dir.join("state.sqlite3"); + if path.exists() { + ensure!( + fs::symlink_metadata(&path)?.is_file(), + "Recovery database must be a regular file" + ); + } + let db = Connection::open(&path)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; + db.busy_timeout(std::time::Duration::from_secs(5))?; + db.execute_batch("PRAGMA synchronous=FULL; BEGIN EXCLUSIVE;")?; + db.execute( + "UPDATE state SET enabled=0,generation=generation+1 WHERE singleton=1", + [], + )?; + db.execute_batch("COMMIT")?; + Ok(()) +} diff --git a/src/subscription.rs b/src/subscription.rs new file mode 100644 index 0000000..df5b318 --- /dev/null +++ b/src/subscription.rs @@ -0,0 +1,305 @@ +use crate::model::{Entry, Trojan, display_text}; +use anyhow::{Result, anyhow, bail, ensure}; +use async_trait::async_trait; +use base64::{Engine, engine::general_purpose}; +use percent_encoding::percent_decode_str; +use std::time::Duration; +use url::Url; +use uuid::Uuid; + +pub const MAX_SUBSCRIPTION_BYTES: usize = 4 * 1024 * 1024; + +#[async_trait] +pub trait SubscriptionSource: Send { + async fn fetch(&self, url: &str) -> Result<Vec<u8>>; +} + +pub struct HttpSource; + +pub fn subscription_url(value: &str) -> Result<Url> { + let url = Url::parse(value).map_err(|_| anyhow!("Invalid subscription URL"))?; + ensure!( + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none(), + "Use an HTTP or HTTPS subscription URL without userinfo or a fragment" + ); + Ok(url) +} + +#[async_trait] +impl SubscriptionSource for HttpSource { + async fn fetch(&self, value: &str) -> Result<Vec<u8>> { + let mut url = subscription_url(value)?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .user_agent(concat!("Roentgen/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| anyhow!("Could not initialize subscription TLS client"))?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + for redirects in 0..=5 { + let mut response = tokio::time::timeout_at( + deadline, + client + .get(url.clone()) + .header("Accept", "text/plain") + .send(), + ) + .await + .map_err(|_| anyhow!("Subscription request timed out"))? + .map_err(|_| anyhow!("Subscription request failed; check connectivity and TLS"))?; + if response.status().is_redirection() { + ensure!( + matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308), + "Unexpected subscription redirect response" + ); + ensure!(redirects < 5, "Subscription exceeded five redirects"); + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow!("Subscription redirect has no valid location"))?; + let next = url + .join(location) + .map_err(|_| anyhow!("Invalid subscription redirect"))?; + subscription_url(next.as_str())?; + ensure!( + !(url.scheme() == "https" && next.scheme() != "https"), + "Refusing HTTPS-to-HTTP subscription redirect" + ); + url = next; + continue; + } + ensure!( + response.status() == reqwest::StatusCode::OK, + "Subscription returned HTTP {}", + response.status().as_u16() + ); + if let Some(kind) = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + { + ensure!( + !["html", "json", "yaml"] + .iter() + .any(|v| kind.to_ascii_lowercase().contains(v)), + "Subscription returned an unsupported document format; use a plain or Base64 link list" + ); + } + let mut body = Vec::new(); + while let Some(chunk) = tokio::time::timeout_at(deadline, response.chunk()) + .await + .map_err(|_| anyhow!("Subscription body timed out"))? + .map_err(|_| anyhow!("Subscription body download failed"))? + { + ensure!( + body.len() + chunk.len() <= MAX_SUBSCRIPTION_BYTES, + "Subscription exceeds the 4 MiB limit" + ); + body.extend_from_slice(&chunk); + } + return Ok(body); + } + unreachable!() + } +} + +pub fn import(bytes: &[u8]) -> Result<Vec<Entry>> { + ensure!( + bytes.len() <= MAX_SUBSCRIPTION_BYTES, + "Subscription exceeds the 4 MiB limit" + ); + let text = std::str::from_utf8(bytes) + .map_err(|_| anyhow!("Subscription is not UTF-8"))? + .trim_start_matches('\u{feff}') + .trim(); + if text.is_empty() { + return Ok(vec![]); + } + let decoded; + let plain = + if text.contains("://") || text.contains('\n') && text.lines().any(|l| l.contains("://")) { + text + } else { + let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + let mut result = None; + for engine in [ + general_purpose::STANDARD, + general_purpose::STANDARD_NO_PAD, + general_purpose::URL_SAFE, + general_purpose::URL_SAFE_NO_PAD, + ] { + if let Ok(value) = engine.decode(&compact) { + result = Some(value); + break; + } + } + decoded = result + .ok_or_else(|| anyhow!("Subscription is neither a link list nor valid Base64"))?; + ensure!( + decoded.len() <= MAX_SUBSCRIPTION_BYTES, + "Decoded subscription exceeds the limit" + ); + std::str::from_utf8(&decoded) + .map_err(|_| anyhow!("Decoded subscription is not UTF-8"))? + .trim() + }; + let lower = plain.to_ascii_lowercase(); + ensure!( + !plain.starts_with(['<', '{', '[']) + && !lower.starts_with("proxies:") + && !lower.starts_with("proxy-providers:"), + "Unsupported subscription envelope; use a plain or Base64 link list" + ); + // A readable empty envelope is accepted, but arbitrary prose is not an entry list. + ensure!( + plain.is_empty() || plain.lines().any(|line| line.contains("://")), + "Subscription does not contain a link list" + ); + Ok(plain + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(parse_entry) + .collect()) +} + +fn decode(value: &str) -> Result<String> { + let bytes = value.as_bytes(); + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'%' { + ensure!( + bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit) + && bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit), + "Invalid percent encoding" + ); + } + } + percent_decode_str(value) + .decode_utf8() + .map(|v| v.into_owned()) + .map_err(|_| anyhow!("Invalid UTF-8 in URI component")) +} + +fn parse_entry(raw: &str) -> Entry { + let mut entry = Entry { + id: Uuid::new_v4().to_string(), + name: "Invalid entry".into(), + status: "invalid".into(), + explanation: None, + connection: None, + }; + let result = (|| -> Result<Trojan> { + ensure!( + !raw.chars().any(char::is_control), + "Control characters in connection link" + ); + let url = Url::parse(raw).map_err(|_| anyhow!("Malformed connection link"))?; + if let Some(label) = url.fragment() { + entry.name = display_text(&decode(label)?); + } + if url.scheme() != "trojan" { + entry.status = "unsupported".into(); + bail!("Only Trojan TCP + TLS connections are supported"); + } + ensure!( + url.password().is_none(), + "Trojan credentials must be a percent-encoded password" + ); + let password = decode(url.username())?; + ensure!(!password.is_empty(), "Missing Trojan password"); + let host = match url + .host() + .ok_or_else(|| anyhow!("Missing connection host"))? + { + url::Host::Domain(v) => v.to_ascii_lowercase(), + url::Host::Ipv4(v) => v.to_string(), + url::Host::Ipv6(v) => v.to_string(), + }; + let port = url + .port() + .ok_or_else(|| anyhow!("Missing connection port"))?; + ensure!(port != 0, "Connection port must be positive"); + if entry.name == "Invalid entry" || entry.name.is_empty() { + entry.name = display_text(&host); + } + let mut server_name = host.clone(); + let mut alpn = vec![]; + let mut fingerprint = None; + let mut seen = std::collections::HashSet::new(); + for (key, value) in url.query_pairs() { + ensure!(seen.insert(key.to_string()), "Duplicate connection option"); + match key.as_ref() { + "type" if matches!(value.as_ref(), "tcp" | "raw") => {} + "security" if value == "tls" => {} + "sni" | "peer" => { + ensure!( + !value.is_empty() && !value.chars().any(char::is_control), + "Invalid TLS server name" + ); + server_name = value.into_owned(); + } + "alpn" => { + alpn = value.split(',').map(str::to_owned).collect(); + ensure!( + alpn.iter().all(|v| !v.is_empty() + && v.len() <= 255 + && !v.chars().any(char::is_control)), + "Invalid TLS ALPN list" + ); + } + "fp" => { + ensure!( + matches!( + value.as_ref(), + "chrome" + | "firefox" + | "safari" + | "ios" + | "android" + | "edge" + | "360" + | "qq" + | "random" + | "randomized" + ), + "Unsupported TLS fingerprint" + ); + fingerprint = Some(value.into_owned()); + } + "allowInsecure" if value == "0" || value == "false" => {} + "headerType" if value == "none" => {} + _ => { + entry.status = "unsupported".into(); + bail!("Unsupported transport, TLS, or connection option"); + } + } + } + ensure!( + !(seen.contains("sni") && seen.contains("peer")), + "Specify only one TLS server name option" + ); + Ok(Trojan { + host, + port, + password, + server_name, + alpn, + fingerprint, + }) + })(); + match result { + Ok(connection) => { + entry.status = "supported".into(); + entry.connection = Some(connection); + } + Err(error) => entry.explanation = Some(error.to_string()), + } + entry +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..9cb69a7 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,565 @@ +use crate::{ + bus::Client, + controller::Operation, + model::{ConfigurationSummary, Settings, Status, SubscriptionSummary}, +}; +use anyhow::{Result, ensure}; +use crossterm::{ + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::{Constraint, Layout}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Tabs}, +}; +use std::{ + io::{self, IsTerminal}, + time::Duration, +}; + +type Screen = Terminal<CrosstermBackend<io::Stdout>>; +struct Restore; +impl Drop for Restore { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + } +} + +struct Ui { + tab: usize, + row: usize, + viewed: Option<String>, + filter: String, + editing_filter: bool, + chooser: bool, + chooser_query: String, + chooser_row: usize, + console: bool, + details: bool, + help: bool, + first_g: bool, + status: Status, + subscriptions: Vec<SubscriptionSummary>, + entries: Vec<ConfigurationSummary>, + settings: Settings, + message: String, + logs: Vec<String>, +} + +impl Ui { + async fn refresh(&mut self, client: &Client) -> Result<()> { + self.status = client.read("Status").await?; + self.subscriptions = client.read("Subscriptions").await?; + if !self + .subscriptions + .iter() + .any(|s| Some(&s.name) == self.viewed.as_ref()) + { + self.viewed = self.subscriptions.first().map(|s| s.name.clone()); + } + self.entries = client.configurations(self.viewed.as_deref()).await?; + self.settings = client.read("Settings").await?; + self.logs = client.read("Logs").await?; + self.row = self.row.min(self.visible().len().saturating_sub(1)); + Ok(()) + } + fn visible(&self) -> Vec<&ConfigurationSummary> { + let query = self.filter.to_lowercase(); + self.entries + .iter() + .filter(|e| { + e.name.to_lowercase().contains(&query) + || e.host + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&query) + }) + .collect() + } + fn choices(&self) -> Vec<&SubscriptionSummary> { + self.subscriptions + .iter() + .filter(|s| fuzzy(&self.chooser_query, &s.name)) + .collect() + } + fn selected_row(&self) -> Option<&ConfigurationSummary> { + self.visible().get(self.row).copied() + } +} + +pub async fn run(client: Client) -> Result<()> { + ensure!( + io::stdin().is_terminal() && io::stdout().is_terminal(), + "The TUI needs a terminal; use status --json for scripts" + ); + let status = client.read("Status").await?; + let mut ui = Ui { + tab: 0, + row: 0, + viewed: None, + filter: String::new(), + editing_filter: false, + chooser: false, + chooser_query: String::new(), + chooser_row: 0, + console: false, + details: false, + help: false, + first_g: false, + status, + subscriptions: vec![], + entries: vec![], + settings: Settings::default(), + message: String::new(), + logs: vec![], + }; + ui.refresh(&client).await?; + terminal::enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + let _restore = Restore; + let mut screen = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let mut last_refresh = std::time::Instant::now(); + loop { + draw(&mut screen, &ui)?; + if !event::poll(Duration::from_millis(200))? { + if last_refresh.elapsed() >= Duration::from_secs(2) { + if let Err(error) = ui.refresh(&client).await { + ui.message = error.to_string(); + } + last_refresh = std::time::Instant::now(); + } + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind == event::KeyEventKind::Release { + continue; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + break; + } + if ui.chooser { + match key.code { + KeyCode::Esc => ui.chooser = false, + KeyCode::Enter => { + if let Some(name) = ui.choices().get(ui.chooser_row).map(|s| s.name.clone()) { + ui.viewed = Some(name); + ui.filter.clear(); + ui.row = 0; + } + ui.chooser = false; + ui.refresh(&client).await?; + } + KeyCode::Down => { + ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1)) + } + KeyCode::Up => ui.chooser_row = ui.chooser_row.saturating_sub(1), + KeyCode::Char('n' | 'j') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ui.chooser_row = (ui.chooser_row + 1).min(ui.choices().len().saturating_sub(1)) + } + KeyCode::Char('p' | 'k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + ui.chooser_row = ui.chooser_row.saturating_sub(1) + } + KeyCode::Backspace => { + ui.chooser_query.pop(); + ui.chooser_row = 0; + } + KeyCode::Char(c) => { + ui.chooser_query.push(c); + ui.chooser_row = 0; + } + _ => {} + } + continue; + } + if ui.editing_filter { + match key.code { + KeyCode::Esc => { + ui.filter.clear(); + ui.editing_filter = false; + } + KeyCode::Enter => ui.editing_filter = false, + KeyCode::Backspace => { + ui.filter.pop(); + } + KeyCode::Char(c) => ui.filter.push(c), + _ => {} + } + ui.row = 0; + continue; + } + if ui.details || ui.help { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) { + ui.details = false; + ui.help = false; + } + continue; + } + let mut operation = None; + match key.code { + KeyCode::Char('q') => break, + KeyCode::Esc => { + ui.tab = 0; + ui.filter.clear(); + } + KeyCode::Char('h') | KeyCode::Left => { + ui.tab = (ui.tab + 2) % 3; + ui.row = 0; + } + KeyCode::Char('l') | KeyCode::Right => { + ui.tab = (ui.tab + 1) % 3; + ui.row = 0; + } + KeyCode::Char('j') | KeyCode::Down => ui.row = ui.row.saturating_add(1), + KeyCode::Char('k') | KeyCode::Up => ui.row = ui.row.saturating_sub(1), + KeyCode::Char('g') if ui.first_g => { + ui.row = 0; + ui.first_g = false; + } + KeyCode::Char('g') => { + ui.first_g = true; + continue; + } + KeyCode::Char('G') => { + ui.row = if ui.tab == 1 { + ui.subscriptions.len() + } else { + ui.visible().len() + } + .saturating_sub(1) + } + KeyCode::Char('/') if ui.tab == 0 => { + ui.editing_filter = true; + } + KeyCode::Char('s') => { + ui.chooser = true; + ui.chooser_query.clear(); + ui.chooser_row = 0; + } + KeyCode::Char('J' | 'K') => { + if !ui.subscriptions.is_empty() { + let index = ui + .subscriptions + .iter() + .position(|s| Some(&s.name) == ui.viewed.as_ref()) + .unwrap_or(0); + let next = if key.code == KeyCode::Char('J') { + (index + 1) % ui.subscriptions.len() + } else { + (index + ui.subscriptions.len() - 1) % ui.subscriptions.len() + }; + ui.viewed = Some(ui.subscriptions[next].name.clone()); + ui.filter.clear(); + ui.row = 0; + ui.refresh(&client).await?; + } + } + KeyCode::Char('c') => ui.console = !ui.console, + KeyCode::Char('?') => ui.help = true, + KeyCode::Char('d') if ui.tab == 0 => ui.details = true, + KeyCode::Char(' ') => { + operation = Some(if ui.status.requested_enabled { + Operation::Disable + } else { + Operation::Enable + }) + } + KeyCode::Char('x') if ui.tab == 0 => operation = Some(Operation::Unset), + KeyCode::Char('u') => { + operation = Some(Operation::Refresh { + name: if ui.tab == 1 { + ui.subscriptions.get(ui.row).map(|s| s.name.clone()) + } else { + ui.viewed.clone() + }, + }) + } + KeyCode::Enter if ui.tab == 0 => { + if let Some(entry) = ui.selected_row() { + operation = Some(Operation::Select { + id: entry.id.clone(), + }); + } + } + KeyCode::Enter if ui.tab == 1 => { + if let Some(s) = ui.subscriptions.get(ui.row) { + ui.viewed = Some(s.name.clone()); + ui.filter.clear(); + ui.row = 0; + ui.tab = 0; + ui.refresh(&client).await?; + } + } + KeyCode::Char('a') if ui.tab == 1 => { + match form( + &mut screen, + "Add subscription", + &["Name", "URL"], + &[String::new(), String::new()], + )? { + Some(values) => { + operation = Some(Operation::AddSubscription { + name: values[0].clone(), + url: values[1].clone(), + }) + } + None => {} + } + } + KeyCode::Char('e') if ui.tab == 1 => { + if let Some(s) = ui.subscriptions.get(ui.row) { + if let Some(values) = form( + &mut screen, + "Edit subscription URL", + &["URL"], + &[String::new()], + )? { + operation = Some(Operation::EditSubscription { + name: s.name.clone(), + url: values[0].clone(), + }); + } + } + } + KeyCode::Char('x') if ui.tab == 1 => { + if let Some(s) = ui.subscriptions.get(ui.row) { + if let Some(values) = form( + &mut screen, + "Type delete to remove this subscription", + &["Confirmation"], + &[String::new()], + )? { + if values[0] == "delete" { + operation = Some(Operation::DeleteSubscription { + name: s.name.clone(), + }); + } + } + } + } + KeyCode::Char('e') | KeyCode::Enter if ui.tab == 2 => { + let current = [ + &ui.settings.ping_url, + &ui.settings.speed_url, + &ui.settings.maximum_download_mib.to_string(), + &ui.settings.maximum_duration_seconds.to_string(), + ] + .map(|v| v.to_string()); + if let Some(values) = form( + &mut screen, + "Settings", + &[ + "HTTPS Ping URL", + "Speed URL", + "Maximum MiB", + "Maximum seconds", + ], + ¤t, + )? { + let parsed = (values[2].parse::<u64>(), values[3].parse::<u64>()); + match parsed { + (Ok(bytes), Ok(seconds)) => { + operation = Some(Operation::SaveSettings { + settings: Settings { + ping_url: values[0].clone(), + speed_url: values[1].clone(), + maximum_download_mib: bytes, + maximum_duration_seconds: seconds, + }, + }) + } + _ => ui.message = "Limits must be positive integers".into(), + } + } + } + KeyCode::Char('e') => { + suspend(&mut screen)?; + let result = crate::editor::open(&ui.logs); + resume(&mut screen)?; + if let Err(error) = result { + ui.message = error.to_string(); + } + } + KeyCode::Char('t' | 'b') if ui.tab == 0 => { + if let Some(entry) = ui.selected_row() { + let id = entry.id.clone(); + suspend(&mut screen)?; + let result = async { + let _agent = crate::authentication::TerminalAgent::start(true)?; + client + .diagnostic(Some(&id), key.code == KeyCode::Char('b'), true) + .await + } + .await; + resume(&mut screen)?; + ui.message = match result { + Ok(value) => value, + Err(error) => error.to_string(), + }; + } + } + _ => {} + } + ui.first_g = false; + if let Some(operation) = operation { + suspend(&mut screen)?; + let result = async { + let _agent = crate::authentication::TerminalAgent::start(true)?; + client.change(operation, true).await + } + .await; + resume(&mut screen)?; + ui.message = match result { + Ok(()) => "Applied".into(), + Err(error) => error.to_string(), + }; + ui.refresh(&client).await?; + } + ui.row = ui.row.min( + if ui.tab == 1 { + ui.subscriptions.len() + } else { + ui.visible().len() + } + .saturating_sub(1), + ); + } + Ok(()) +} + +fn suspend(screen: &mut Screen) -> Result<()> { + terminal::disable_raw_mode()?; + execute!(screen.backend_mut(), LeaveAlternateScreen)?; + Ok(()) +} +fn resume(screen: &mut Screen) -> Result<()> { + terminal::enable_raw_mode()?; + execute!(screen.backend_mut(), EnterAlternateScreen)?; + screen.clear()?; + Ok(()) +} + +fn form( + screen: &mut Screen, + title: &str, + labels: &[&str], + initial: &[String], +) -> Result<Option<Vec<String>>> { + let mut values = initial.to_vec(); + let mut field = 0; + let mut discarding = false; + loop { + screen.draw(|frame| { + let text = labels + .iter() + .enumerate() + .map(|(i, label)| { + let value = if *label == "URL" { + "*".repeat(values[i].chars().count()) + } else { + values[i].clone() + }; + format!("{} {label}: {value}", if field == i { ">" } else { " " }) + }) + .collect::<Vec<_>>() + .join("\n\n"); + let help = if discarding { + "Discard edits? y confirms, any other key resumes" + } else { + "Tab moves fields. Enter saves from the last field. Esc cancels." + }; + frame.render_widget( + Paragraph::new(format!("{text}\n\n{help}")) + .block(Block::default().borders(Borders::ALL).title(title)), + frame.area(), + ); + })?; + let Event::Key(key) = event::read()? else { + continue; + }; + if discarding { + if key.code == KeyCode::Char('y') { + return Ok(None); + } + discarding = false; + continue; + } + match key.code { + KeyCode::Esc => { + if values == initial { + return Ok(None); + } + discarding = true; + } + KeyCode::Tab => field = (field + 1) % values.len(), + KeyCode::BackTab => field = (field + values.len() - 1) % values.len(), + KeyCode::Enter if field + 1 == values.len() => return Ok(Some(values)), + KeyCode::Enter => field += 1, + KeyCode::Backspace => { + values[field].pop(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if values[field].len() < 16384 { + values[field].push(c); + } + } + _ => {} + } + } +} + +fn draw(screen: &mut Screen, ui: &Ui) -> Result<()> { + screen.draw(|frame| { + let layout = Layout::vertical([Constraint::Length(1), Constraint::Length(2), Constraint::Min(4), Constraint::Length(if ui.console { 6 } else { 0 }), Constraint::Length(2)]).split(frame.area()); + frame.render_widget(Tabs::new(["Connections", "Subscriptions", "Settings"]).select(ui.tab).highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), layout[0]); + let actions = match ui.tab { + 0 => format!("{} [s] Update [u] Details [d] Ping [t] Speed [b] {}", ui.viewed.as_deref().unwrap_or("No subscription"), if ui.filter.is_empty() && !ui.editing_filter { String::new() } else { format!("/ {}", ui.filter) }), + 1 => "Add [a] Edit [e] Update [u] Delete [x] Open [Enter]".into(), + _ => "Edit settings [e]".into(), + }; + frame.render_widget(Paragraph::new(actions), layout[1]); + if ui.help { + frame.render_widget(Paragraph::new("h/l: screens j/k: rows gg/G: first/last\nEnter: select/open Space: enable/disable x: unset/delete\ns: subscription chooser J/K: cycle subscriptions /: filter\nd: details t: Ping b: Speed c: console e: logs/editor\n?: help Esc: back/clear q: quit\nSelection and viewed subscription are separate.\nClosing this interface leaves the service running."), layout[2]); + } else if ui.details { + let text = ui.selected_row().map(|e| format!("{}\nID: {}\nHost: {}\nPort: {}\nStatus: {}\n{}", e.name, e.id, e.host.as_deref().unwrap_or("unavailable"), e.port.map(|p| p.to_string()).unwrap_or_default(), e.status, e.explanation.as_deref().unwrap_or("Trojan / TCP / TLS"))).unwrap_or_else(|| "No configuration highlighted".into()); + frame.render_widget(Paragraph::new(text), layout[2]); + } else if ui.chooser { + let choices = ui.choices(); + let rows = choices.iter().map(|s| Row::new([s.name.as_str()])); + let mut state = TableState::default().with_selected(Some(ui.chooser_row)); + frame.render_stateful_widget(Table::new(rows, [Constraint::Percentage(100)]).block(Block::default().borders(Borders::ALL).title(format!("Choose subscription / {}", ui.chooser_query))).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); + } else if ui.tab == 0 { + let rows = ui.visible().into_iter().map(|entry| Row::new([ + Cell::from(if entry.selected { "*" } else { " " }), + Cell::from(format!("{}{}", if entry.status == "supported" { String::new() } else { format!("[{}] ", entry.status) }, entry.name)), + Cell::from(entry.host.clone().unwrap_or_default()), Cell::from(entry.ping_ms.map(|v| format!("{v} ms")).unwrap_or_else(|| "?".into())), Cell::from(entry.speed_bytes_per_second.map(|v| format!("{:.1} MiB/s", v / 1048576.0)).unwrap_or_else(|| "?".into())), + ])); + let mut state = TableState::default().with_selected(Some(ui.row)); + frame.render_stateful_widget(Table::new(rows, [Constraint::Length(1), Constraint::Percentage(40), Constraint::Percentage(35), Constraint::Length(9), Constraint::Length(9)]).header(Row::new(["", "Name", "Host", "Delay", "Speed"])).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); + } else if ui.tab == 1 { + let mut state = TableState::default().with_selected(Some(ui.row)); + frame.render_stateful_widget(Table::new(ui.subscriptions.iter().map(|s| Row::new([s.name.as_str()])), [Constraint::Percentage(100)]).row_highlight_style(Style::default().bg(Color::DarkGray)), layout[2], &mut state); + } else { + frame.render_widget(Paragraph::new(format!("HTTPS Ping URL: {}\nSpeed URL: {}\nMaximum download MiB: {}\nMaximum duration seconds: {}", ui.settings.ping_url, ui.settings.speed_url, ui.settings.maximum_download_mib, ui.settings.maximum_duration_seconds)), layout[2]); + } + if ui.console { frame.render_widget(Paragraph::new(ui.logs.iter().rev().take(5).cloned().collect::<Vec<_>>().join("\n")).block(Block::default().borders(Borders::TOP)), layout[3]); } + frame.render_widget(Paragraph::new(format!("{} | Routing: {} | Health: {} | Selected: {}\n{}", if ui.status.requested_enabled { "Enabled" } else { "Disabled" }, ui.status.routing, ui.status.health, ui.status.selected_configuration.as_deref().unwrap_or("unset"), ui.message)), layout[4]); + })?; + Ok(()) +} + +fn fuzzy(query: &str, value: &str) -> bool { + let value = value.to_lowercase(); + let mut chars = value.chars(); + query + .to_lowercase() + .chars() + .all(|wanted| chars.by_ref().any(|c| c == wanted)) +} diff --git a/src/xray.rs b/src/xray.rs new file mode 100644 index 0000000..5ac8b62 --- /dev/null +++ b/src/xray.rs @@ -0,0 +1,206 @@ +use crate::model::Trojan; +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use std::{ + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::Path, + process::Stdio, + time::Duration, +}; +use tokio::process::Command; + +pub const RUNTIME_DIR: &str = "/run/roentgen"; + +pub fn identity() -> Result<(u32, u32)> { + let mut buffer = vec![0u8; 65536]; + let mut record = std::mem::MaybeUninit::<libc::passwd>::uninit(); + let mut result = std::ptr::null_mut(); + // SAFETY: the output record and buffer are writable and remain alive while + // the reentrant account lookup fills them. The returned IDs are copied. + let error = unsafe { + libc::getpwnam_r( + c"roentgen-xray".as_ptr(), + record.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + ensure!( + error == 0 && !result.is_null(), + "Missing roentgen-xray service account; run roentgen install" + ); + let record = unsafe { record.assume_init() }; + ensure!( + record.pw_uid != 0 && record.pw_gid != 0, + "Xray must use a dedicated non-root account and group" + ); + Ok((record.pw_uid, record.pw_gid)) +} + +pub async fn preflight() -> Result<()> { + let metadata = + fs::metadata("/usr/bin/xray").context("Install ALT's current xray package first")?; + ensure!( + metadata.uid() == 0 && metadata.permissions().mode() & 0o022 == 0, + "System Xray must be root-owned and not writable by other users" + ); + let output = tokio::time::timeout( + Duration::from_secs(5), + Command::new("/usr/bin/xray").arg("version").output(), + ) + .await??; + ensure!( + output.status.success(), + "Could not read system Xray version" + ); + let version = String::from_utf8_lossy(&output.stdout); + let version = version.split_whitespace().nth(1).unwrap_or(""); + // Native TUN routing behavior is pinned to the researched release family. + ensure!( + matches!(version, "26.10.29"), + "Unsupported Xray release; this build requires validated Xray 26.10.29 capabilities" + ); + ensure!( + Path::new("/dev/net/tun").exists(), + "Linux TUN is unavailable" + ); + Ok(()) +} + +pub fn configuration(connection: &Trojan, tun: bool, probe: Option<(u16, &str)>) -> Value { + let mut inbounds = vec![]; + if tun { + inbounds.push(json!({"tag":"host-tun","protocol":"tun","settings":{"name":"roentgen0","MTU":1500,"gateway":["198.18.0.1/30","fdfe:726f:656e::1/126"],"autoSystemRoutingTable":[],"autoOutboundsInterface":"","autoSystemDnsToGateway":false}})); + } + if let Some((port, password)) = probe { + inbounds.push(json!({"tag":"probe","listen":"127.0.0.1","port":port,"protocol":"socks","settings":{"auth":"password","accounts":[{"user":"roentgen","pass":password}],"udp":false}})); + } + let mut tls = json!({"serverName":connection.server_name,"allowInsecure":false}); + if !connection.alpn.is_empty() { + tls["alpn"] = json!(connection.alpn); + } + if let Some(fingerprint) = &connection.fingerprint { + tls["fingerprint"] = json!(fingerprint); + } + json!({"log":{"loglevel":"none"},"inbounds":inbounds,"outbounds":[{"tag":"proxy","protocol":"trojan","settings":{"servers":[{"address":connection.host,"port":connection.port,"password":connection.password}]},"streamSettings":{"network":"tcp","security":"tls","tlsSettings":tls}}]}) +} + +pub fn write_config(path: &Path, value: &Value) -> Result<()> { + let (_, gid) = identity()?; + fs::create_dir_all(RUNTIME_DIR)?; + let directory = Path::new(RUNTIME_DIR); + fs::set_permissions(directory, fs::Permissions::from_mode(0o750))?; + std::os::unix::fs::chown(directory, Some(0), Some(gid))?; + crate::storage::atomic_private_write(path, &serde_json::to_vec(value)?)?; + std::os::unix::fs::chown(path, Some(0), Some(gid))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o640))?; + Ok(()) +} + +pub async fn systemctl(args: &[&str]) -> Result<()> { + let status = tokio::time::timeout( + Duration::from_secs(30), + Command::new("/usr/bin/systemctl") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(), + ) + .await??; + ensure!( + status.success(), + "Service operation failed; inspect systemctl status for Roentgen" + ); + Ok(()) +} + +pub struct Probe { + unit: String, + path: std::path::PathBuf, + pub proxy: String, + active: bool, +} +impl Probe { + pub async fn start(connection: &Trojan, duration: u64) -> Result<Self> { + preflight().await?; + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let token = uuid::Uuid::new_v4().simple().to_string(); + let unit = format!("roentgen-probe-{token}"); + let path = Path::new(RUNTIME_DIR).join(format!("{unit}.json")); + write_config( + &path, + &configuration(connection, false, Some((port, &token))), + )?; + drop(listener); + let runtime_limit = format!("RuntimeMaxSec={}", duration.saturating_add(10)); + let result = Command::new("/usr/bin/systemd-run") + .args([ + "--quiet", + "--collect", + "--unit", + &unit, + "--uid=roentgen-xray", + "--property=NoNewPrivileges=yes", + "--property=PrivateTmp=yes", + "--property=ProtectSystem=strict", + "--property=ProtectHome=yes", + "--property", + &runtime_limit, + "/usr/bin/xray", + "run", + "-config", + ]) + .arg(&path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + let probe = Self { + unit, + path, + proxy: format!("socks5h://roentgen:{token}@127.0.0.1:{port}"), + active: true, + }; + if !result?.success() { + probe.stop().await; + anyhow::bail!("Could not launch protected Xray probe"); + } + for _ in 0..50 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return Ok(probe); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + probe.stop().await; + anyhow::bail!("Protected Xray probe did not start") + } + pub async fn stop(mut self) { + self.active = false; + let _ = systemctl(&["stop", &self.unit]).await; + let _ = fs::remove_file(&self.path); + } +} + +impl Drop for Probe { + fn drop(&mut self) { + if !self.active { + return; + } + let unit = self.unit.clone(); + let path = self.path.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = systemctl(&["stop", &unit]).await; + let _ = fs::remove_file(path); + }); + } + // systemd RuntimeMaxSec also bounds the process after controller death. + } +} diff --git a/tests/cli_acceptance.rs b/tests/cli_acceptance.rs new file mode 100644 index 0000000..97def0c --- /dev/null +++ b/tests/cli_acceptance.rs @@ -0,0 +1,31 @@ +use std::process::Command; + +#[test] +fn help_describes_the_controller_commands_without_contacting_services() { + let output = Command::new(binary()).arg("--help").output().unwrap(); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + for command in [ + "subscription", + "configuration", + "enable", + "disable", + "status", + "recover", + ] { + assert!(text.contains(command), "Missing command {command}: {text}"); + } +} + +#[test] +fn subscription_url_cannot_be_passed_on_the_command_line() { + let output = Command::new(binary()) + .args(["subscription", "add", "https://secret.example/token"]) + .output() + .unwrap(); + assert!(!output.status.success()); +} + +fn binary() -> String { + std::env::var("ROENTGEN_TEST_BINARY").unwrap_or_else(|_| env!("CARGO_BIN_EXE_roentgen").into()) +} diff --git a/tests/controller_acceptance.rs b/tests/controller_acceptance.rs new file mode 100644 index 0000000..0d83cd1 --- /dev/null +++ b/tests/controller_acceptance.rs @@ -0,0 +1,355 @@ +use async_trait::async_trait; +use roentgen::controller::{Caller, Controller, Operation}; +use roentgen::runtime::OfflineRuntime; +use roentgen::subscription::SubscriptionSource; + +struct FixtureSource(String); +impl FixtureSource { + fn new(value: &str) -> Self { + Self(value.into()) + } +} +#[async_trait] +impl SubscriptionSource for FixtureSource { + async fn fetch(&self, _: &str) -> anyhow::Result<Vec<u8>> { + Ok(self.0.as_bytes().to_vec()) + } +} +use tempfile::TempDir; + +#[tokio::test] +async fn importing_requires_explicit_selection_and_enabling() { + let home = TempDir::new().unwrap(); + let mut controller = Controller::open( + home.path(), + Box::new(OfflineRuntime), + Box::new(FixtureSource::new( + "trojan://secret@example.com:443?security=tls&type=tcp#First\n", + )), + ) + .unwrap(); + let root = Caller::root(); + controller + .execute( + &root, + Operation::AddSubscription { + name: "Personal".into(), + url: "https://provider.example/token".into(), + }, + ) + .await + .unwrap(); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); + assert!(!controller.status().unwrap().requested_enabled); + let entries = controller.configurations(None).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "First"); + assert_eq!(entries[0].status, "supported"); + assert!(controller.execute(&root, Operation::Enable).await.is_err()); + assert!(!controller.status().unwrap().requested_enabled); +} + +#[derive(Clone)] +struct MutableSource(std::sync::Arc<std::sync::Mutex<Option<String>>>); +#[async_trait] +impl SubscriptionSource for MutableSource { + async fn fetch(&self, _: &str) -> anyhow::Result<Vec<u8>> { + self.0 + .lock() + .unwrap() + .as_ref() + .map(|v| v.as_bytes().to_vec()) + .ok_or_else(|| anyhow::anyhow!("Fixture fetch failed")) + } +} + +struct GuardedRuntime { + armed: bool, + routing: &'static str, +} +#[async_trait] +impl roentgen::runtime::Runtime for GuardedRuntime { + async fn arm(&mut self) -> anyhow::Result<()> { + self.armed = true; + self.routing = "blocked"; + Ok(()) + } + async fn apply( + &mut self, + connection: Option<&roentgen::model::Trojan>, + _: u64, + ) -> anyhow::Result<()> { + anyhow::ensure!(self.armed, "Attempted unprotected activation"); + self.routing = if connection.is_some() { + "tun" + } else { + "blocked" + }; + Ok(()) + } + async fn disable(&mut self) -> anyhow::Result<()> { + self.armed = false; + self.routing = "direct"; + Ok(()) + } + fn routing(&self) -> &str { + self.routing + } +} + +async fn fixture() -> (TempDir, Controller, MutableSource) { + let home = TempDir::new().unwrap(); + let source = MutableSource(std::sync::Arc::new(std::sync::Mutex::new(Some( + "trojan://secret@example.com:443?security=tls#First\n".into(), + )))); + let mut controller = Controller::open( + home.path(), + Box::new(GuardedRuntime { + armed: false, + routing: "direct", + }), + Box::new(source.clone()), + ) + .unwrap(); + controller + .execute( + &Caller::root(), + Operation::AddSubscription { + name: "Personal".into(), + url: "https://provider.example/token".into(), + }, + ) + .await + .unwrap(); + (home, controller, source) +} + +#[tokio::test] +async fn losing_a_selection_keeps_enabled_intent_and_blocks_without_replacement() { + let (_home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id }) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Enable) + .await + .unwrap(); + *source.0.lock().unwrap() = Some("trojan://another@other.example:443#Replacement\n".into()); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + let status = controller.status().unwrap(); + assert!(status.requested_enabled); + assert!(status.selected_configuration.is_none()); + assert_eq!(status.routing, "blocked"); +} + +#[tokio::test] +async fn failed_fetch_preserves_selection_but_an_empty_received_list_clears_it() { + let (_home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + *source.0.lock().unwrap() = None; + assert!( + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .is_err() + ); + assert_eq!( + controller + .status() + .unwrap() + .selected_configuration + .as_deref(), + Some(id.as_str()) + ); + *source.0.lock().unwrap() = Some(String::new()); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); + assert!(controller.configurations(None).unwrap().is_empty()); +} + +#[tokio::test] +async fn mixed_base64_import_keeps_all_rows_and_prohibits_invalid_selection() { + use base64::Engine; + let (_home, mut controller, source) = fixture().await; + let body = "trojan://secret@example.com:443#Good\nvless://other@example.com:443#Unsupported\ntrojan://missing-port@host#Broken\n"; + *source.0.lock().unwrap() = Some(base64::engine::general_purpose::STANDARD.encode(body)); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + let entries = controller.configurations(None).unwrap(); + assert_eq!( + entries + .iter() + .map(|e| e.status.as_str()) + .collect::<Vec<_>>(), + ["supported", "unsupported", "invalid"] + ); + for entry in entries.iter().skip(1) { + assert!( + controller + .execute( + &Caller::root(), + Operation::Select { + id: entry.id.clone() + } + ) + .await + .is_err() + ); + } + let public = serde_json::to_string(&entries).unwrap(); + assert!(!public.contains("secret")); + assert!(!public.contains("missing-port")); +} + +#[tokio::test] +async fn denied_operations_leave_durable_state_unchanged() { + let (_home, mut controller, _source) = fixture().await; + let before = serde_json::to_string(&controller.status().unwrap()).unwrap(); + assert!( + controller + .execute( + &Caller::denied(), + Operation::DeleteSubscription { + name: "Personal".into() + } + ) + .await + .is_err() + ); + assert_eq!( + serde_json::to_string(&controller.status().unwrap()).unwrap(), + before + ); + assert_eq!(controller.subscriptions().len(), 1); +} + +#[tokio::test] +async fn explicit_unset_and_deletion_never_disable_or_select_another_entry() { + let (_home, mut controller, _source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Enable) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Unset) + .await + .unwrap(); + assert!(controller.status().unwrap().requested_enabled); + assert_eq!(controller.status().unwrap().routing, "blocked"); + assert_eq!(controller.configurations(None).unwrap().len(), 1); + controller + .execute(&Caller::root(), Operation::Select { id }) + .await + .unwrap(); + controller + .execute( + &Caller::root(), + Operation::DeleteSubscription { + name: "Personal".into(), + }, + ) + .await + .unwrap(); + assert!(controller.status().unwrap().requested_enabled); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); + assert_eq!(controller.status().unwrap().routing, "blocked"); +} + +#[tokio::test] +async fn persisted_intent_restores_under_protection_after_controller_restart() { + let (home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Enable) + .await + .unwrap(); + drop(controller); + let mut restored = Controller::open( + home.path(), + Box::new(GuardedRuntime { + armed: false, + routing: "unknown", + }), + Box::new(source), + ) + .unwrap(); + restored.restore().await.unwrap(); + assert_eq!(restored.status().unwrap().selected_configuration, Some(id)); + assert!(restored.status().unwrap().requested_enabled); + assert_eq!(restored.status().unwrap().routing, "tun"); +} + +#[tokio::test] +async fn label_changes_keep_identity_but_duplicate_candidates_clear_selection() { + let (_home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + *source.0.lock().unwrap() = Some("trojan://secret@example.com:443#Renamed\n".into()); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + assert_eq!( + controller.status().unwrap().selected_configuration, + Some(id) + ); + *source.0.lock().unwrap() = Some( + "trojan://secret@example.com:443#Duplicate1\ntrojan://secret@example.com:443#Duplicate2\n" + .into(), + ); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); +} diff --git a/tests/vm_controller_acceptance.py b/tests/vm_controller_acceptance.py new file mode 100644 index 0000000..d39b16f --- /dev/null +++ b/tests/vm_controller_acceptance.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Exercise the actual CLI/system-bus/polkit boundary on the supplied ALT VM.""" +import contextlib +import http.server +import json +import os +from pathlib import Path +import socket +import subprocess +import threading +import time + +ROOT = Path('/var/tmp/roentgen-implementation') +BIN = str(ROOT / 'roentgen') +BODY = b'trojan://never-print-password@example.com:443?security=tls#First\n' + + +def command(*args, input=None, ok=True, user=None, env=None): + argv = (["runuser", "-u", user, "--"] if user else []) + list(args) + result = subprocess.run(argv, input=input, text=True, capture_output=True, timeout=40, env=env) + if ok and result.returncode: + raise AssertionError(f'{args[0:2]} failed: {result.stderr}') + if not ok and not result.returncode: + raise AssertionError(f'{args[0:2]} unexpectedly succeeded') + return result + + +def cli(*args, **kwargs): return command(BIN, *args, **kwargs) + + +def status(): return json.loads(cli('status', '--json').stdout) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + self.wfile.write(BODY) + def log_message(self, *args): pass + + +def main(): + global BODY + assert os.geteuid() == 0 and socket.gethostname() == 'altctlrs' + assert (ROOT / 'allow-network-tests').exists() + # Test setup installs integration fixtures directly. This is deliberately not + # an assertion that product install succeeds with the VM's unsupported core. + Path('/etc/roentgen').mkdir(mode=0o700, exist_ok=True) + Path('/var/lib/roentgen').mkdir(mode=0o700, exist_ok=True) + assets = ROOT / 'assets' + destinations = { + 'roentgen.service': '/etc/systemd/system/roentgen.service', + 'roentgen-guard.service': '/etc/systemd/system/roentgen-guard.service', + 'roentgen-xray.service': '/etc/systemd/system/roentgen-xray.service', + 'org.roentgen.Controller1.conf': '/etc/dbus-1/system.d/org.roentgen.Controller1.conf', + 'org.roentgen.policy': '/usr/share/polkit-1/actions/org.roentgen.policy', + } + for source, destination in destinations.items(): + Path(destination).write_bytes((assets / source).read_bytes()) + Path(destination).chmod(0o644) + target = Path('/usr/local/bin/roentgen') + target.write_bytes(Path(BIN).read_bytes()); target.chmod(0o755) + command('systemctl', 'daemon-reload') + command('busctl', 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ReloadConfig') + command('systemctl', 'start', 'roentgen-guard.service') + command('systemctl', 'start', 'roentgen.service') + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + token_url = f'http://127.0.0.1:{server.server_port}/never-print-subscription-token' + if 'Personal' in cli('subscription', 'list').stdout: cli('subscription', 'delete', 'Personal') + results = [] + policy = Path('/etc/polkit-1/rules.d/00-roentgen-vm-test.rules') + try: + cli('subscription', 'add', '--name', 'Personal', input=token_url + '\n') + before = status() + assert before['selected_configuration'] is None and not before['requested_enabled'] + public = cli('subscription', 'list').stdout + cli('configuration', 'list').stdout + cli('status', '--json').stdout + assert 'never-print-password' not in public and 'never-print-subscription-token' not in public + results.append('real HTTP import remains disabled/unselected and public CLI output omits credentials') + cli('enable', ok=False) + assert status()['generation'] == before['generation'] + rows = command('busctl', 'call', 'org.roentgen.Controller1', '/org/roentgen/Controller1', 'org.roentgen.Controller1', 'Configurations', 's', '').stdout + # busctl prints a quoted JSON string. Decode that string before its JSON payload. + entries = json.loads(json.loads(rows[2:].strip())) + selected = entries[0]['id'] + cli('configuration', 'select', selected) + before = status() + denied = cli('configuration', 'unset', ok=False, user='rustacean') + assert status()['selected_configuration'] == selected and status()['generation'] == before['generation'] + assert 'authorization' in denied.stderr.lower() or 'accessdenied' in denied.stderr.lower() + results.append('noninteractive non-root mutation is denied with durable state unchanged') + policy.write_text('polkit.addRule(function(action, subject) { if (action.id == "org.roentgen.control" && subject.user == "rustacean") return polkit.Result.YES; });\n') + policy.chmod(0o644) + time.sleep(1) + cli('configuration', 'unset', user='rustacean') + assert status()['selected_configuration'] is None + results.append('polkit site allow policy authorizes the actual ordinary bus caller') + policy.unlink() + time.sleep(1) + cli('configuration', 'select', selected) + BODY = b'trojan://never-print-password@example.com:443#Renamed\nvless://id@example.com:443#Unsupported\ntrojan://broken@host#Invalid\n' + cli('subscription', 'update', 'Personal') + listing = cli('configuration', 'list').stdout + assert 'Renamed' in listing and 'unsupported' in listing and 'invalid' in listing + assert status()['selected_configuration'] == selected + results.append('mixed update retains ordered invalid/unsupported rows and normalized identity') + BODY = b'<html>provider login page</html>' + cli('subscription', 'update', 'Personal', ok=False) + assert status()['selected_configuration'] == selected + BODY = b'' + cli('subscription', 'update', 'Personal') + assert status()['selected_configuration'] is None + results.append('whole-document failure preserves selection while received empty list clears it') + BODY = b'trojan://never-print-password@example.com:443#First\n' + cli('subscription', 'update', '--all') + output = command('busctl', 'call', 'org.roentgen.Controller1', '/org/roentgen/Controller1', 'org.roentgen.Controller1', 'Configurations', 's', '').stdout + selected = json.loads(json.loads(output[2:].strip()))[0]['id'] + cli('configuration', 'select', selected) + error = cli('enable', ok=False).stderr + assert 'Unsupported Xray release' in error + assert not status()['requested_enabled'] + results.append('unsupported packaged Xray activation is rejected before enabled intent changes') + command('systemctl', 'restart', 'roentgen.service') + assert status()['selected_configuration'] == selected + results.append('service restart retains explicit selection and disabled intent') + editor = ROOT / 'check-editor.py' + editor.write_text('import os,pathlib,sys\np=pathlib.Path(sys.argv[-1])\nassert p.suffix==".txt" and (p.stat().st_mode & 0o777)==0o600\nassert p.stat().st_uid==os.getuid()\nassert sys.argv[1:3]==["--literal","two words"]\npathlib.Path("/var/tmp/roentgen-editor-path").write_text(str(p))\n') + env = dict(os.environ, EDITOR=f'python3 {editor} --literal "two words"') + cli('logs', '--editor', user='rustacean', env=env) + snapshot = Path('/var/tmp/roentgen-editor-path').read_text() + assert not Path(snapshot).exists() + results.append('editor receives literal arguments and a user-owned private snapshot that is removed afterward') + cli('subscription', 'delete', 'Personal') + assert status()['selected_configuration'] is None + results.append('subscription deletion clears selection without replacement') + print(json.dumps({'passed': results}, indent=2)) + finally: + server.shutdown() + if policy.exists(): policy.unlink() + command('systemctl', 'stop', 'roentgen.service', ok=True) + + +if __name__ == '__main__': main() diff --git a/tests/vm_guard_acceptance.py b/tests/vm_guard_acceptance.py new file mode 100644 index 0000000..ff99c33 --- /dev/null +++ b/tests/vm_guard_acceptance.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""CLI acceptance checks. Run as root inside the authorized disposable ALT VM.""" +import contextlib +import json +import os +from pathlib import Path +import socket +import subprocess +import threading +import time + +ROOT = Path('/var/tmp/roentgen-implementation') +BINARY = ROOT / 'roentgen' + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=20, **kwargs) + + +def guard(enabled): + temporary = Path('/etc/roentgen/guard.toml.tmp') + temporary.write_text(f'version = 1\nenabled = {str(enabled).lower()}\n') + temporary.chmod(0o600) + temporary.replace('/etc/roentgen/guard.toml') + run(str(BINARY), 'guard') + + +def echo(listener): + while True: + try: + connection, _ = listener.accept() + except OSError: + return + threading.Thread(target=stream, args=(connection,), daemon=True).start() + + +def stream(connection): + with connection: + while data := connection.recv(1024): + connection.sendall(data) + + +def exchange(connection): + connection.sendall(b'roentgen-acceptance') + assert connection.recv(1024) == b'roentgen-acceptance' + + +def connect(host, port): + return socket.create_connection((host, port), timeout=1) + + +def main(): + assert os.geteuid() == 0 + assert socket.gethostname() == 'altctlrs', 'This test is restricted to the supplied VM' + assert (ROOT / 'allow-network-tests').exists(), 'Missing VM-only authorization marker' + Path('/etc/roentgen').mkdir(mode=0o700, exist_ok=True) + run('id', 'roentgen-xray') + run('ip', 'netns', 'add', 'roentgen-peer') + children = [] + listeners = [] + results = [] + try: + run('ip', 'link', 'add', 'rgtest0', 'type', 'veth', 'peer', 'name', 'rgpeer0', 'netns', 'roentgen-peer') + for address in ['198.51.100.1/24', '10.254.0.1/24', '2001:db8:726f::1/64', 'fded:726f::1/64']: + run('ip', 'address', 'add', address, 'dev', 'rgtest0', *(['nodad'] if ':' in address else [])) + run('ip', 'link', 'set', 'rgtest0', 'up') + for address in ['198.51.100.2/24', '10.254.0.2/24', '2001:db8:726f::2/64', 'fded:726f::2/64']: + run('ip', 'netns', 'exec', 'roentgen-peer', 'ip', 'address', 'add', address, 'dev', 'rgpeer0', *(['nodad'] if ':' in address else [])) + run('ip', 'netns', 'exec', 'roentgen-peer', 'ip', 'link', 'set', 'rgpeer0', 'up') + run('ip', 'netns', 'exec', 'roentgen-peer', 'ip', 'link', 'set', 'lo', 'up') + server = '''import socket,threading,time + +def serve(host,port): + s=socket.socket(socket.AF_INET6 if ':' in host else socket.AF_INET);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind((host,port));s.listen() + def handle(c): + with c: + while True: + data=c.recv(1024) + if not data:return + c.sendall(data) + while True: + c,_=s.accept();threading.Thread(target=handle,args=(c,),daemon=True).start() +for host in ['198.51.100.2','10.254.0.2','2001:db8:726f::2','fded:726f::2']: + for port in [53,18080]:threading.Thread(target=serve,args=(host,port),daemon=True).start() +time.sleep(120) +''' + children.append(subprocess.Popen(['ip', 'netns', 'exec', 'roentgen-peer', 'python3', '-u', '-c', server], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)) + time.sleep(2) + guard(False) + outgoing = connect('198.51.100.2', 18080) + exchange(outgoing) + listener = socket.socket() + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(('198.51.100.1', 18081)) + listener.listen() + listeners.append(listener) + threading.Thread(target=echo, args=(listener,), daemon=True).start() + client = '''import socket,sys +s=socket.create_connection(('198.51.100.1',18081),timeout=3) +print('ready',flush=True) +for line in sys.stdin: + s.sendall(b'keep-incoming');assert s.recv(1024)==b'keep-incoming';print('ok',flush=True) +''' + incoming = subprocess.Popen(['ip', 'netns', 'exec', 'roentgen-peer', 'python3', '-u', '-c', client], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + children.append(incoming) + assert incoming.stdout.readline().strip() == 'ready' + incoming.stdin.write('before\n'); incoming.stdin.flush() + assert incoming.stdout.readline().strip() == 'ok' + guard(True) + for host in ['10.254.0.2', 'fded:726f::2']: + for port in [53,18080]: + try: + with connect(host, port) as connection: exchange(connection) + except Exception as error: + raise AssertionError(f'Private direct connection failed: {host}:{port}') from error + results.append('private IPv4/IPv6 destinations stay direct on ports 53 and 18080') + for host in ['198.51.100.2', '2001:db8:726f::2']: + for port in [53,18080]: + try: + with connect(host, port) as connection: exchange(connection) + except (TimeoutError, OSError): + pass + else: + raise AssertionError(f'Public direct fallback leaked to {host}:{port}') + results.append('public IPv4/IPv6 destinations are blocked on ports 53 and 18080') + incoming.stdin.write('enabled\n'); incoming.stdin.flush() + assert incoming.stdout.readline().strip() == 'ok' + guard(False) + guard(True) + incoming.stdin.write('after-toggle\n'); incoming.stdin.flush() + assert incoming.stdout.readline().strip() == 'ok' + results.append('classified incoming TCP connection survives guard replacement and toggles') + try: + exchange(outgoing) + except (TimeoutError, OSError): + pass + else: + raise AssertionError('A preexisting direct outgoing connection bypassed the guard') + outgoing.close() + results.append('preexisting outgoing TCP connection receives no established-flow exemption') + guard(False) + with connect('198.51.100.2', 18080) as connection: exchange(connection) + results.append('explicit disabled guard restores ordinary direct routing') + print(json.dumps({'passed': results}, indent=2)) + finally: + import sys + if sys.exc_info()[0]: print(run('nft', 'list', 'table', 'inet', 'roentgen').stdout, flush=True) + with contextlib.suppress(Exception): guard(False) + for child in children: + child.terminate() + with contextlib.suppress(Exception): child.wait(timeout=3) + if child.poll() is None: child.kill() + for listener in listeners: listener.close() + subprocess.run(['ip', 'link', 'del', 'rgtest0'], capture_output=True) + subprocess.run(['ip', 'netns', 'del', 'roentgen-peer'], capture_output=True) + + +if __name__ == '__main__': main() |