diff --git a/Cargo.lock b/Cargo.lock index 19b927b..ab01e22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,12 +74,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -175,9 +169,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-lc-rs" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd82dba44d209fddb11c190e0a94b78651f95299598e472215667417a03ff1d" +checksum = "fe7c2840b66236045acd2607d5866e274380afd87ef99d6226e961e2cb47df45" dependencies = [ "aws-lc-sys", "mirai-annotations", @@ -187,9 +181,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7a4168111d7eb622a31b214057b8509c0a7e1794f44c546d742330dc793972" +checksum = "ad3a619a9de81e1d7de1f1186dcba4506ed661a0e483d84410fdef0ee87b2f96" dependencies = [ "bindgen", "cc", @@ -202,9 +196,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.7" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", @@ -311,7 +305,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -386,21 +380,6 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -412,9 +391,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.37" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40545c26d092346d8a8dab71ee48e7685a7a9cba76e634790c215b41a4a7b4cf" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ "jobserver", "libc", @@ -484,9 +463,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.20" +version = "4.5.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" dependencies = [ "clap_builder", "clap_derive", @@ -494,9 +473,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.20" +version = "4.5.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" dependencies = [ "anstream", "anstyle", @@ -518,9 +497,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" [[package]] name = "cmake" @@ -537,20 +516,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "compact_str" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -585,9 +550,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" dependencies = [ "libc", ] @@ -626,31 +591,6 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.6.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix", - "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 = "crunchy" version = "0.2.2" @@ -935,9 +875,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.34" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "miniz_oxide", @@ -961,12 +901,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1173,11 +1107,6 @@ name = "hashbrown" version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] [[package]] name = "heck" @@ -1214,7 +1143,7 @@ dependencies = [ "ipnet", "once_cell", "rand", - "thiserror 1.0.68", + "thiserror 1.0.69", "tinyvec", "tokio", "tracing", @@ -1237,7 +1166,7 @@ dependencies = [ "rand", "resolv-conf", "smallvec", - "thiserror 1.0.68", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -1548,12 +1477,6 @@ dependencies = [ "hashbrown 0.15.1", ] -[[package]] -name = "indoc" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" - [[package]] name = "inotify" version = "0.10.2" @@ -1584,16 +1507,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instability" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "instant" version = "0.1.13" @@ -1636,15 +1549,6 @@ dependencies = [ "either", ] -[[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.11" @@ -1706,9 +1610,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.162" +version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" [[package]] name = "libloading" @@ -1780,15 +1684,6 @@ dependencies = [ "fid-rs", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.1", -] - [[package]] name = "lru-cache" version = "0.1.2" @@ -1864,9 +1759,9 @@ checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" [[package]] name = "mockall" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c28b3fb6d753d28c20e826cd46ee611fda1cf3cde03a443a974043247c065a" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" dependencies = [ "cfg-if", "downcast", @@ -1878,9 +1773,9 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "341014e7f530314e9a1fdbc7400b244efea7122662c96bfa248c31da5bfb2020" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" dependencies = [ "cfg-if", "proc-macro2", @@ -2063,7 +1958,7 @@ dependencies = [ "delegate", "futures", "rand", - "thiserror 1.0.68", + "thiserror 1.0.69", "tokio", "windows", ] @@ -2310,24 +2205,12 @@ dependencies = [ ] [[package]] -name = "ratatui" -version = "0.29.0" +name = "rand_seeder" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "4a9febe641d2842ffc76ee962668a17578767c4e01735e4802b21ed9a24b2e4e" dependencies = [ - "bitflags 2.6.0", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "rand_core", ] [[package]] @@ -2485,7 +2368,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror 1.0.68", + "thiserror 1.0.69", "tokio", ] @@ -2546,7 +2429,7 @@ dependencies = [ "spki", "ssh-encoding", "ssh-key", - "thiserror 1.0.68", + "thiserror 1.0.69", "tokio", "tokio-stream", "typenum", @@ -2566,7 +2449,7 @@ dependencies = [ "flurry", "log", "serde", - "thiserror 1.0.68", + "thiserror 1.0.69", "tokio", "tokio-util", ] @@ -2606,9 +2489,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.39" +version = "0.38.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" dependencies = [ "bitflags 2.6.0", "errno", @@ -2619,9 +2502,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.16" +version = "0.23.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +checksum = "7f1a745511c54ba6d4465e8d5dfbd81b45791756de28d4981af70d6dca128f1e" dependencies = [ "aws-lc-rs", "log", @@ -2701,7 +2584,7 @@ dependencies = [ "pretty-duration", "rand", "rand_chacha", - "ratatui", + "rand_seeder", "russh", "russh-keys", "rustls", @@ -2759,18 +2642,18 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", @@ -2779,9 +2662,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -2839,27 +2722,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -2977,40 +2839,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[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", -] - [[package]] name = "subtle" version = "2.6.1" @@ -3059,11 +2893,11 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" [[package]] name = "thiserror" -version = "1.0.68" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.68", + "thiserror-impl 1.0.69", ] [[package]] @@ -3077,9 +2911,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "1.0.68" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", @@ -3297,7 +3131,7 @@ dependencies = [ "log", "rand", "sha1", - "thiserror 1.0.68", + "thiserror 1.0.69", "utf-8", ] @@ -3328,35 +3162,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "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 = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index cb8a10b..8f2e371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,11 @@ name = "sandhole" version = "0.1.0" edition = "2021" +repository = "https://github.com/EpicEric/sandhole" +license = "MIT" +authors = ["Eric Rodrigues Pires "] +readme = "README.md" +categories = ["network-programming", "web-programming", "authentication"] [dependencies] anyhow = "1.0.93" @@ -14,12 +19,12 @@ dashmap = "6.1.0" hickory-resolver = "0.24.1" http-body-util = "0.1.2" hyper = { version = "1.5.0", features = ["full"] } -hyper-util = { version = "0.1.10", features = ["full"] } +hyper-util = { version = "0.1", features = ["full"] } notify = "7.0.0" pretty-duration = "0.1.1" rand = "0.8.5" rand_chacha = "0.3.1" -ratatui = "0.29.0" +rand_seeder = "0.3.0" russh = "0.46.0" russh-keys = "0.46.0" rustls = "0.23.16" diff --git a/README.md b/README.md index 7f4de0b..14f0ce1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ If you're looking for a complete solution, check out [sish](https://github.com/a Roughly in the order I intend to work on: -- Integration tests +- Option to time out responses that take too long - HTTPS redirection - API-based password authentication - ACME for certificates @@ -20,5 +20,15 @@ Roughly in the order I intend to work on: - Generic TCP forwarding - Local port forwarding - Use env_logger +- Documentation - Improve technical debts - And more + +## Features + +- Written in Rust. +- HTTP port forwarding through SSH. +- Automatic HTTPS support (with a tool like [dnsrobocert](https://github.com/adferrand/dnsrobocert)). +- Automatic subdomain assignment (by default), with options for deterministic assignment. +- Authenticate proxy tunnels through DNS, via a TXT record containing the authorized key's fingerprint. +- Comprehensive testing of features. diff --git a/src/addressing.rs b/src/addressing.rs index 1443c07..3239233 100644 --- a/src/addressing.rs +++ b/src/addressing.rs @@ -1,14 +1,12 @@ -use std::{ - hash::{DefaultHasher, Hash, Hasher}, - net::SocketAddr, -}; +use std::{hash::Hash, net::SocketAddr}; use async_trait::async_trait; use hickory_resolver::TokioAsyncResolver; #[cfg(test)] use mockall::automock; -use rand::{seq::SliceRandom, thread_rng, RngCore, SeedableRng}; +use rand::{seq::SliceRandom, thread_rng, Rng, RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; +use rand_seeder::SipHasher; use webpki::types::DnsName; use crate::config::RandomSubdomainSeed; @@ -99,7 +97,14 @@ impl AddressDelegator { socket_address: &SocketAddr, ) -> String { if self.bind_any_host { - return requested_address.to_string(); + if DnsName::try_from(requested_address).is_ok() { + return requested_address.to_string(); + } else if self.force_random_subdomains { + eprintln!( + "Invalid address requested, defaulting to random: {}", + requested_address + ); + } } if !self.force_random_subdomains { if DnsName::try_from(requested_address).is_ok() { @@ -150,7 +155,7 @@ impl AddressDelegator { fingerprint: &Option, socket_address: &SocketAddr, ) -> String { - let mut hasher = DefaultHasher::default(); + let mut hasher = SipHasher::default(); self.seed.hash(&mut hasher); let mut hash_initialized = false; if let Some(strategy) = self.random_subdomain_seed { @@ -187,8 +192,9 @@ impl AddressDelegator { thread_rng().next_u64().hash(&mut hasher); } // Generate random subdomain from hashed state - // TODO: Use more entropy than 64 bits - let mut rng = ChaCha20Rng::seed_from_u64(hasher.finish()); + let mut seed: ::Seed = Default::default(); + hasher.into_rng().fill(&mut seed); + let mut rng = ChaCha20Rng::from_seed(seed); String::from_utf8( (0..6) .flat_map(|_| { diff --git a/src/certificates.rs b/src/certificates.rs index 85f8517..7066704 100644 --- a/src/certificates.rs +++ b/src/certificates.rs @@ -163,8 +163,10 @@ mod certificate_resolver_tests { use super::CertificateResolver; static CERTIFICATES_DIRECTORY: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/test_data/certificates"); + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates"); + // Certificate is valid for "foobar.tld" and "*.foobar.tld" static DOMAINS_FOOBAR: &[&str] = &["foobar.tld", "something.foobar.tld", "other.foobar.tld"]; + // Certificate is valid for "localhost" static DOMAINS_LOCALHOST: &[&str] = &["localhost"]; static UNKNOWN_DOMAINS: &[&str] = &[".invalid.", "tld", "example.com", "too.nested.foobar.tld"]; diff --git a/src/config.rs b/src/config.rs index 15b4c01..605d394 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ pub enum RandomSubdomainSeed { #[derive(Debug)] pub struct ApplicationConfig { pub domain: String, + pub domain_redirect: String, pub public_keys_directory: PathBuf, pub certificates_directory: PathBuf, pub private_key_file: PathBuf, diff --git a/src/fingerprints.rs b/src/fingerprints.rs index c5a1b28..a01a42d 100644 --- a/src/fingerprints.rs +++ b/src/fingerprints.rs @@ -89,7 +89,7 @@ mod fingerprints_validator_tests { use russh_keys::{key::PublicKey, parse_public_key_base64}; static PUBLIC_KEYS_DIRECTORY: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/test_data/public_keys"); + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys"); static KEY_ONE: LazyLock = LazyLock::new(|| { parse_public_key_base64( "AAAAC3NzaC1lZDI1NTE5AAAAIMYVfXHTqf3/0W8ZQ/I8zmMirvmosV78n1qtYgVQX58W", diff --git a/src/http.rs b/src/http.rs index 4208d52..3cab359 100644 --- a/src/http.rs +++ b/src/http.rs @@ -5,7 +5,7 @@ use std::{net::SocketAddr, sync::Arc}; use super::error::ServerError; use async_trait::async_trait; use axum::body::Body as AxumBody; -use axum::response::IntoResponse; +use axum::response::{IntoResponse, Redirect}; use dashmap::DashMap; use hyper::body::Body; use hyper::header::{HOST, UPGRADE}; @@ -136,6 +136,7 @@ pub(crate) async fn proxy_handler( mut request: Request, tcp_address: SocketAddr, conn_manager: Arc>>, + domain_redirect: Arc<(String, String)>, ) -> anyhow::Result> where H: HttpHandler, @@ -155,6 +156,9 @@ where .ok_or(ServerError::InvalidHostHeader)? .to_owned(); let Some(handler) = conn_manager.get(&host) else { + if &domain_redirect.0 == &host { + return Ok(Redirect::to(&domain_redirect.1).into_response()); + } return Ok((StatusCode::NOT_FOUND, "").into_response()); }; request.headers_mut().insert( @@ -249,6 +253,7 @@ mod proxy_handler_tests { request, "127.0.0.1:12345".parse().unwrap(), Arc::clone(&conn_manager), + Arc::new(("main.domain".into(), "https://example.com".into())), ) .await; assert!(response.is_err()); @@ -264,11 +269,11 @@ mod proxy_handler_tests { .header("host", "no.handler") .body(Empty::::new()) .unwrap(); - dbg!(&request); let response = proxy_handler( request, "127.0.0.1:12345".parse().unwrap(), Arc::clone(&conn_manager), + Arc::new(("main.domain".into(), "https://example.com".into())), ) .await; assert!(response.is_ok()); @@ -276,6 +281,32 @@ mod proxy_handler_tests { assert_eq!(response.status(), hyper::StatusCode::NOT_FOUND); } + #[tokio::test] + async fn returns_redirect_for_root_domain_and_missing_handler() { + let conn_manager: Arc>>> = + Arc::new(ConnectionMap::new()); + let request = Request::builder() + .method("GET") + .uri("http://main.domain/index.html") + .header("host", "main.domain") + .body(Empty::::new()) + .unwrap(); + let response = proxy_handler( + request, + "127.0.0.1:12345".parse().unwrap(), + Arc::clone(&conn_manager), + Arc::new(("main.domain".into(), "https://example.com".into())), + ) + .await; + assert!(response.is_ok()); + let response = response.unwrap(); + assert_eq!(response.status(), hyper::StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "https://example.com" + ); + } + #[tokio::test] async fn returns_response_for_existing_handler() { let conn_manager: Arc>>> = @@ -327,6 +358,71 @@ mod proxy_handler_tests { request, "127.0.0.1:12345".parse().unwrap(), Arc::clone(&conn_manager), + Arc::new(("main.domain".into(), "https://example.com".into())), + ) + .await; + assert!(!logging_rx.is_empty()); + assert!(response.is_ok()); + let response = response.unwrap(); + assert_eq!(response.status(), hyper::StatusCode::OK); + let body = response.into_body(); + let body = axum::body::to_bytes(body, 32).await.unwrap(); + assert_eq!(body, bytes::Bytes::from("Success.")); + jh.abort(); + } + + #[tokio::test] + async fn returns_response_for_handler_of_root_domain() { + let conn_manager: Arc>>> = + Arc::new(ConnectionMap::new()); + let (server, handler) = tokio::io::duplex(1024); + let (logging_tx, logging_rx) = mpsc::channel::>(1); + let mut mock = MockHttpHandler::new(); + mock.expect_log_channel() + .once() + .return_once(move || logging_tx); + mock.expect_tunneling_channel() + .once() + .return_once(move || Ok(TokioIo::new(handler))); + conn_manager.insert( + "root.domain".into(), + "127.0.0.1:12345".parse().unwrap(), + Arc::new(mock), + ); + let request = Request::builder() + .method("POST") + .uri("http://root.domain/test") + .header("host", "root.domain") + .body(String::from("My body")) + .unwrap(); + let router = axum::Router::new() + .route( + "/test", + axum::routing::post(|headers: HeaderMap, body: String| async move { + if headers.get("X-Forwarded-For").unwrap() == "192.168.0.1" + && headers.get("X-Forwarded-Host").unwrap() == "root.domain" + && body == "My body" + { + "Success." + } else { + "Failure." + } + }), + ) + .into_service(); + let router_service = service_fn(move |req: Request| router.clone().call(req)); + let jh = tokio::spawn(async move { + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection(TokioIo::new(server), router_service) + .await + .expect("Invalid request"); + }); + assert!(logging_rx.is_empty()); + let response = proxy_handler( + request, + "192.168.0.1:12345".parse().unwrap(), + Arc::clone(&conn_manager), + Arc::new(("root.domain".into(), "https://this.is.ignored".into())), ) .await; assert!(!logging_rx.is_empty()); @@ -384,6 +480,7 @@ mod proxy_handler_tests { request, "127.0.0.1:12345".parse().unwrap(), Arc::clone(&conn_manager), + Arc::new(("main.domain".into(), "https://example.com".into())), ) }); let jh2 = tokio::spawn(async move { diff --git a/src/lib.rs b/src/lib.rs index ce11a99..ffc598a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,11 +38,33 @@ pub(crate) struct SandholeServer { } pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { - let key = fs::read_to_string(config.private_key_file.as_path()) - .await - .with_context(|| "Error reading secret key")?; - let key = decode_secret_key(&key, config.private_key_password.as_deref()) - .with_context(|| "Error decoding secret key")?; + let key = match fs::read_to_string(config.private_key_file.as_path()).await { + Ok(key) => decode_secret_key(&key, config.private_key_password.as_deref()) + .with_context(|| "Error decoding secret key")?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(err).with_context(|| "Error reading secret key"); + // TO-DO: Allow generating key file on startup + // println!("Key file not found. Creating..."); + // let key = russh_keys::key::KeyPair::generate_ed25519(); + // fs::create_dir_all( + // config + // .private_key_file + // .as_path() + // .parent() + // .ok_or(ServerError::InvalidFilePath) + // .with_context(|| "Error parsing secret key path")?, + // ) + // .await + // .with_context(|| "Error creating secret key directory")?; + // fs::write( + // config + // .private_key_file + // .as_path(), + // ... + // ) + } + Err(err) => return Err(err).with_context(|| "Error reading secret key"), + }; let http_connections = Arc::new(ConnectionMap::new()); let fingerprints = Arc::new( @@ -63,17 +85,25 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { config.force_random_subdomains, config.random_subdomain_seed, )); + let domain_redirect = Arc::new((config.domain, config.domain_redirect)); let http_listener = TcpListener::bind((config.listen_address.clone(), config.http_port)) .await .with_context(|| "Error listening to HTTP port and address")?; let http_map = Arc::clone(&http_connections); + let redirect = Arc::clone(&domain_redirect); tokio::spawn(async move { loop { - let map_clone = Arc::clone(&http_map); + let http_map = Arc::clone(&http_map); + let domain_redirect = Arc::clone(&redirect); let (stream, address) = http_listener.accept().await.unwrap(); let service = service_fn(move |req: Request| { - proxy_handler(req, address, Arc::clone(&map_clone)) + proxy_handler( + req, + address, + Arc::clone(&http_map), + Arc::clone(&domain_redirect), + ) }); let io = TokioIo::new(stream); tokio::spawn(async move { @@ -95,11 +125,17 @@ pub async fn entrypoint(config: ApplicationConfig) -> anyhow::Result<()> { let http_map = Arc::clone(&http_connections); tokio::spawn(async move { loop { - let map_clone = Arc::clone(&http_map); + let http_map = Arc::clone(&http_map); + let domain_redirect = Arc::clone(&domain_redirect); let acceptor = acceptor.clone(); let (stream, address) = https_listener.accept().await.unwrap(); let service = service_fn(move |req: Request| { - proxy_handler(req, address, Arc::clone(&map_clone)) + proxy_handler( + req, + address, + Arc::clone(&http_map), + Arc::clone(&domain_redirect), + ) }); let io = match acceptor.accept(stream).await { Ok(stream) => TokioIo::new(stream), diff --git a/src/main.rs b/src/main.rs index cd64cde..ed06477 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use sandhole::{ config::{ApplicationConfig, RandomSubdomainSeed as Seed}, entrypoint, }; +use webpki::types::DnsName; #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] pub enum RandomSubdomainSeed { @@ -30,27 +31,36 @@ impl From for Seed { #[command(version, about, long_about = None)] struct Args { /// The root domain of the application. - #[arg(long)] + #[arg(long, value_parser = validate_domain)] domain: String, + /// Where to redirect requests to the root domain. + #[arg(long, default_value_t = String::from(env!("CARGO_PKG_REPOSITORY")))] + domain_redirect: String, + /// Directory containing authorized public keys. + /// Each file must contain exactly one key. #[arg(long, default_value_os = "./deploy/public_keys/")] public_keys_directory: PathBuf, /// Directory containing SSL certificates and keys. - /// Each sub-directory inside of this one must contain a certificate in a + /// Each sub-directory inside of this one must contain a certificate chain in a /// `fullchain.pem` file and its private key in a `privkey.pem` file. #[arg(long, default_value_os = "./deploy/certificates/")] certificates_directory: PathBuf, /// File path to the server's secret key. - #[arg(long, default_value_os = "./deploy/server_keys/ssh_key")] + #[arg(long, default_value_os = "./deploy/server_keys/ssh")] private_key_file: PathBuf, /// Password to use for the server's secret key, if any. #[arg(long)] private_key_password: Option, + /// Whether to create a private key file if missing. + // #[arg(long, default_value_t = true)] + // create_private_key_file: bool, + /// Address to listen for all client connections. #[arg(long, default_value_t = String::from("0.0.0.0"))] listen_address: String, @@ -76,15 +86,18 @@ struct Args { force_random_subdomains: bool, /// Which value to seed with when generating random subdomains, for determinism. This allows binding to the same - /// random address, as long as Sandhole isn't restarted, but can lead to collisions if misused. + /// random address until Sandhole is restarted. + /// + /// Beware that this can lead to collisions if misused! /// /// If unset, defaults to a random seed. #[arg(long, value_enum)] random_subdomain_seed: Option, /// Prefix for TXT DNS records containing key fingerprints, for authorization to bind under a specific domain. + /// /// In other words, valid records will be of the form: `TXT PREFIX.CUSTOM_DOMAIN SHA256:...` - #[arg(long, default_value_t = String::from("_sandhole"))] + #[arg(long, default_value_t = String::from("_sandhole"), value_parser = validate_txt_record_prefix)] txt_record_prefix: String, } @@ -93,6 +106,7 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); let config = ApplicationConfig { domain: args.domain, + domain_redirect: args.domain_redirect, public_keys_directory: args.public_keys_directory, certificates_directory: args.certificates_directory, private_key_file: args.private_key_file, @@ -108,3 +122,17 @@ async fn main() -> anyhow::Result<()> { }; entrypoint(config).await } + +fn validate_domain(domain: &str) -> Result { + DnsName::try_from(domain).map_err(|_| "invalid domain")?; + Ok(domain.to_string()) +} + +fn validate_txt_record_prefix(prefix: &str) -> Result { + DnsName::try_from(prefix).map_err(|_| "invalid prefix")?; + if prefix.find('.').is_some() { + Err("prefix cannot contain period".into()) + } else { + Ok(prefix.to_string()) + } +} diff --git a/src/ssh.rs b/src/ssh.rs index 96208fb..33ddc6f 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -154,7 +154,7 @@ impl Handler for ServerHandler { port: &mut u32, session: &mut Session, ) -> Result { - // TO-DO: Handle more than plain HTTP + // TO-DO: Handle more than HTTP if *port == 0 { *port = 80; } else if *port != 80 { @@ -218,7 +218,7 @@ impl Handler for ServerHandler { port: u32, _session: &mut Session, ) -> Result { - // TO-DO: Handle more than plain HTTP + // TO-DO: Handle more than HTTP if port != 80 { return Err(russh::Error::RequestDenied); } diff --git a/tests/bind_any_host.rs b/tests/bind_any_host.rs new file mode 100644 index 0000000..4eede61 --- /dev/null +++ b/tests/bind_any_host.rs @@ -0,0 +1,185 @@ +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use axum::{ + extract::{Path, Request}, + routing::get, + Router, +}; +use http_body_util::BodyExt; +use hyper::{body::Incoming, service::service_fn, StatusCode}; +use hyper_util::{ + rt::{TokioExecutor, TokioIo}, + server::conn::auto::Builder, +}; +use russh::{ + client::{self, Msg, Session}, + Channel, +}; +use russh_keys::{key, load_secret_key}; +use rustls::{ + pki_types::{pem::PemObject, CertificateDer}, + RootCertStore, +}; +use sandhole::{config::ApplicationConfig, entrypoint}; +use tokio::{ + net::TcpStream, + time::{sleep, timeout}, +}; +use tokio_rustls::TlsConnector; +use tower::Service; + +#[tokio::test(flavor = "multi_thread")] +async fn bind_any_host() { + // 1. Initialize Sandhole + let config = ApplicationConfig { + domain: "foobar.tld".into(), + domain_redirect: "https://tokio.rs/".into(), + public_keys_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/public_keys") + .into(), + certificates_directory: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/certificates") + .into(), + private_key_file: concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/server_keys/ssh").into(), + private_key_password: None, + listen_address: "127.0.0.1".into(), + ssh_port: 18022, + http_port: 18080, + https_port: 18443, + bind_any_host: true, + force_random_subdomains: true, + random_subdomain_seed: None, + txt_record_prefix: "_sandhole".into(), + }; + tokio::spawn(async move { entrypoint(config).await }); + if let Err(_) = timeout(Duration::from_secs(5), async { + while let Err(_) = TcpStream::connect("127.0.0.1:18022").await { + sleep(Duration::from_millis(100)).await; + } + }) + .await + { + panic!("Timeout waiting for Sandhole to start.") + }; + + // 2. Start SSH client that will be proxied + let key = load_secret_key( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/private_keys/key1"), + None, + ) + .expect("Missing file key1"); + let ssh_config = Arc::new(russh::client::Config::default()); + let ssh_client = SshClient { + router: Router::new().route( + "/:user", + get(|Path(user): Path| async move { format!("Hello, {user}!") }), + ), + }; + let mut session = russh::client::connect(ssh_config, "127.0.0.1:18022", ssh_client) + .await + .expect("Failed to connect to SSH server"); + assert!(session + .authenticate_publickey("user", Arc::new(key)) + .await + .expect("SSH authentication failed")); + // let channel = session + // .channel_open_session() + // .await + // .expect("channel_open_session failed"); + session + .tcpip_forward("test.foobar.tld", 80) + .await + .expect("tcpip_forward failed"); + + // 3. Connect to the HTTPS port of our proxy + let mut root_store = RootCertStore::empty(); + root_store.add_parsable_certificates( + CertificateDer::pem_file_iter(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/ca/rootCA.pem" + )) + .and_then(|iter| iter.collect::, _>>()) + .unwrap(), + ); + let tls_config = Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(), + ); + let connector = TlsConnector::from(tls_config); + let tcp_stream = TcpStream::connect("127.0.0.1:18443") + .await + .expect("TCP connection failed"); + let tls_stream = connector + .connect("test.foobar.tld".try_into().unwrap(), tcp_stream) + .await + .unwrap(); + let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls_stream)) + .await + .unwrap(); + tokio::spawn(async move { + if let Err(err) = conn.await { + println!("Connection failed: {:?}", err); + } + }); + let request = Request::builder() + .method("GET") + .uri("https://test.foobar.tld:18443/world") + .header("host", "test.foobar.tld") + .body(http_body_util::Empty::::new()) + .unwrap(); + let Ok(response) = timeout(Duration::from_secs(5), async move { + sender + .send_request(request) + .await + .expect("Error sending HTTP request") + }) + .await + else { + panic!("Timeout waiting for request to finish."); + }; + assert!(response.status() == StatusCode::OK); + let response_body = String::from_utf8( + response + .into_body() + .collect() + .await + .expect("Error collecting response") + .to_bytes() + .into(), + ) + .expect("Invalid response body"); + assert_eq!(response_body, "Hello, world!"); +} + +struct SshClient { + router: Router, +} + +#[async_trait] +impl client::Handler for SshClient { + type Error = anyhow::Error; + + async fn check_server_key(&mut self, _key: &key::PublicKey) -> Result { + Ok(true) + } + + async fn server_channel_open_forwarded_tcpip( + &mut self, + channel: Channel, + _connected_address: &str, + _connected_port: u32, + _originator_address: &str, + _originator_port: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { + let router = self.router.clone().into_service(); + let service = service_fn(move |req: Request| router.clone().call(req)); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(channel.into_stream()), service) + .await + .expect("Invalid request"); + }); + Ok(()) + } +} diff --git a/test_data/ca/rootCA-key.pem b/tests/data/ca/rootCA-key.pem similarity index 100% rename from test_data/ca/rootCA-key.pem rename to tests/data/ca/rootCA-key.pem diff --git a/test_data/ca/rootCA.pem b/tests/data/ca/rootCA.pem similarity index 100% rename from test_data/ca/rootCA.pem rename to tests/data/ca/rootCA.pem diff --git a/test_data/certificates/foobar.tld/fullchain.pem b/tests/data/certificates/foobar.tld/fullchain.pem similarity index 100% rename from test_data/certificates/foobar.tld/fullchain.pem rename to tests/data/certificates/foobar.tld/fullchain.pem diff --git a/test_data/certificates/foobar.tld/privkey.pem b/tests/data/certificates/foobar.tld/privkey.pem similarity index 100% rename from test_data/certificates/foobar.tld/privkey.pem rename to tests/data/certificates/foobar.tld/privkey.pem diff --git a/test_data/certificates/localhost/fullchain.pem b/tests/data/certificates/localhost/fullchain.pem similarity index 100% rename from test_data/certificates/localhost/fullchain.pem rename to tests/data/certificates/localhost/fullchain.pem diff --git a/test_data/certificates/localhost/privkey.pem b/tests/data/certificates/localhost/privkey.pem similarity index 100% rename from test_data/certificates/localhost/privkey.pem rename to tests/data/certificates/localhost/privkey.pem diff --git a/test_data/private_keys/key1 b/tests/data/private_keys/key1 similarity index 100% rename from test_data/private_keys/key1 rename to tests/data/private_keys/key1 diff --git a/test_data/private_keys/key2 b/tests/data/private_keys/key2 similarity index 100% rename from test_data/private_keys/key2 rename to tests/data/private_keys/key2 diff --git a/test_data/public_keys/key1.pub b/tests/data/public_keys/key1.pub similarity index 100% rename from test_data/public_keys/key1.pub rename to tests/data/public_keys/key1.pub diff --git a/test_data/public_keys/key2.pub b/tests/data/public_keys/key2.pub similarity index 100% rename from test_data/public_keys/key2.pub rename to tests/data/public_keys/key2.pub diff --git a/tests/data/server_keys/ssh b/tests/data/server_keys/ssh new file mode 100644 index 0000000..4bf7778 --- /dev/null +++ b/tests/data/server_keys/ssh @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACASaGBMIeiPcsUVCYEhqp3iRJavWadThHCxtKxxXEkPyQAAAJCUlHXKlJR1 +ygAAAAtzc2gtZWQyNTUxOQAAACASaGBMIeiPcsUVCYEhqp3iRJavWadThHCxtKxxXEkPyQ +AAAED9v4BCfMKHWvf/xFVsKhYJk0uPfytv7tTpjjjNYoD+7RJoYEwh6I9yxRUJgSGqneJE +lq9Zp1OEcLG0rHFcSQ/JAAAACHNhbmRob2xlAQIDBAU= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/data/server_keys/ssh.pub b/tests/data/server_keys/ssh.pub new file mode 100644 index 0000000..d97d298 --- /dev/null +++ b/tests/data/server_keys/ssh.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJoYEwh6I9yxRUJgSGqneJElq9Zp1OEcLG0rHFcSQ/J sandhole