feat: implement HTTPS mTLS tunnel with auth handshake
Add HTTPS default tunnel endpoint and outbound connector using Rustls with mTLS and an additional auth/session token. - Add rustls, tokio-rustls, hyper, and related dependencies - Implement TLS config builders (server/client) with cert validation - Implement HTTPS tunnel listener with mTLS accept and auth handshake - Implement HTTPS tunnel connector with server cert validation - Add auth token loading from CLI value or file - Add security gate state machine for tracking auth progression - Fail closed for missing/invalid TLS config, cert mismatch, auth failures - Port conflicts fail without fallback - Reconnect revalidates all security gates - 67 tests covering all validation assertions
This commit is contained in:
Generated
+494
-1
@@ -134,12 +134,40 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-rs"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-sys",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-sys"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cmake",
|
||||||
|
"dunce",
|
||||||
|
"fs_extra",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -161,6 +189,15 @@ version = "2.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -174,6 +211,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -223,12 +262,56 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
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.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -272,6 +355,16 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -283,6 +376,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -311,12 +410,72 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[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.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -328,6 +487,18 @@ dependencies = [
|
|||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -336,11 +507,30 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -368,6 +558,99 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
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.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -386,6 +669,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -398,6 +687,16 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -585,6 +884,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -634,6 +939,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -746,6 +1057,31 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.40"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
@@ -755,21 +1091,43 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustunnel"
|
name = "rustunnel"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"rand",
|
"rand",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"x509-parser 0.16.0",
|
"x509-parser 0.16.0",
|
||||||
@@ -830,6 +1188,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -855,6 +1224,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -877,6 +1252,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -899,6 +1280,27 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"core-foundation",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -1020,6 +1422,41 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -1081,6 +1518,18 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -1111,6 +1560,21 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -1175,6 +1639,35 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|||||||
+10
@@ -19,6 +19,16 @@ x509-parser = "0.16"
|
|||||||
time = { version = "0.3", features = ["formatting"] }
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging"] }
|
||||||
|
rustls-pki-types = "1"
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
hyper = { version = "1", features = ["full"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["full", "server-graceful"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
tower-service = "0.3"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
+10
-2
@@ -33,8 +33,12 @@ pub enum Commands {
|
|||||||
ca_cert: String,
|
ca_cert: String,
|
||||||
|
|
||||||
/// Auth/session token required from connecting clients
|
/// Auth/session token required from connecting clients
|
||||||
#[arg(long, value_name = "TOKEN")]
|
#[arg(long, default_value = "", value_name = "TOKEN")]
|
||||||
auth_token: String,
|
auth_token: String,
|
||||||
|
|
||||||
|
/// Path to a file containing the auth/session token (alternative to --auth-token)
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
auth_token_file: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Connect to the HTTPS tunnel listener and expose a local SOCKS5 proxy
|
/// Connect to the HTTPS tunnel listener and expose a local SOCKS5 proxy
|
||||||
@@ -64,8 +68,12 @@ pub enum Commands {
|
|||||||
ca_cert: String,
|
ca_cert: String,
|
||||||
|
|
||||||
/// Auth/session token to authenticate with the listener
|
/// Auth/session token to authenticate with the listener
|
||||||
#[arg(long, value_name = "TOKEN")]
|
#[arg(long, default_value = "", value_name = "TOKEN")]
|
||||||
auth_token: String,
|
auth_token: String,
|
||||||
|
|
||||||
|
/// Path to a file containing the auth/session token (alternative to --auth-token)
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
auth_token_file: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Generate local certificate and auth material
|
/// Generate local certificate and auth material
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
/// Error types for the HTTPS mTLS tunnel.
|
||||||
|
///
|
||||||
|
/// All errors are designed to be actionable and fail-closed — they never
|
||||||
|
/// silently degrade to an insecure path.
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors that occur when loading or configuring TLS material.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TlsError {
|
||||||
|
#[error("missing {0}")]
|
||||||
|
Missing(&'static str),
|
||||||
|
|
||||||
|
#[error("invalid {0}: {1}")]
|
||||||
|
Invalid(&'static str, String),
|
||||||
|
|
||||||
|
#[error("certificate identity mismatch: {0}")]
|
||||||
|
IdentityMismatch(String),
|
||||||
|
|
||||||
|
#[error("certificate expired: {0}")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Expired(String),
|
||||||
|
|
||||||
|
#[error("certificate verification failed: {0}")]
|
||||||
|
VerificationFailed(String),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("rustls error: {0}")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Rustls(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that occur during application-level authentication.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error("auth token missing")]
|
||||||
|
Missing,
|
||||||
|
|
||||||
|
#[error("auth token invalid")]
|
||||||
|
Invalid,
|
||||||
|
|
||||||
|
#[error("auth token expired")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Expired,
|
||||||
|
|
||||||
|
#[error("too many auth attempts")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
TooManyAttempts,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that occur during tunnel lifecycle operations.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TunnelError {
|
||||||
|
#[error("TLS error: {0}")]
|
||||||
|
Tls(#[from] TlsError),
|
||||||
|
|
||||||
|
#[error("auth error: {0}")]
|
||||||
|
Auth(#[from] AuthError),
|
||||||
|
|
||||||
|
#[error("bind error on {0}: {1}")]
|
||||||
|
BindError(String, String),
|
||||||
|
|
||||||
|
#[error("connection refused: {0}")]
|
||||||
|
ConnectionRefused(String),
|
||||||
|
|
||||||
|
#[error("connection closed")]
|
||||||
|
ConnectionClosed,
|
||||||
|
|
||||||
|
#[error("port {0} already in use")]
|
||||||
|
PortInUse(u16),
|
||||||
|
|
||||||
|
#[error("shutdown requested")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Shutdown,
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("protocol error: {0}")]
|
||||||
|
Protocol(String),
|
||||||
|
|
||||||
|
#[error("timeout: {0}")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Timeout(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors returned by the connector side.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ConnectorError {
|
||||||
|
#[error("TLS handshake failed: {0}")]
|
||||||
|
TlsHandshake(String),
|
||||||
|
|
||||||
|
#[error("server certificate verification failed: {0}")]
|
||||||
|
ServerCertVerification(String),
|
||||||
|
|
||||||
|
#[error("server certificate identity mismatch for {0}: {1}")]
|
||||||
|
ServerIdentityMismatch(String, String),
|
||||||
|
|
||||||
|
#[error("server certificate expired")]
|
||||||
|
ServerCertExpired,
|
||||||
|
|
||||||
|
#[error("auth rejected: {0}")]
|
||||||
|
AuthRejected(String),
|
||||||
|
|
||||||
|
#[error("connection failed: {0}")]
|
||||||
|
ConnectionFailed(String),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("protocol error: {0}")]
|
||||||
|
Protocol(String),
|
||||||
|
|
||||||
|
#[error("timeout: {0}")]
|
||||||
|
Timeout(String),
|
||||||
|
}
|
||||||
+89
-16
@@ -1,13 +1,20 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod errors;
|
||||||
mod generate;
|
mod generate;
|
||||||
mod redact;
|
mod redact;
|
||||||
|
mod tls;
|
||||||
|
mod tunnel;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::Path;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
|
use crate::tunnel::{ConnectorConfig, ListenerConfig};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Initialize tracing/logger — secrets are never printed by design
|
// Initialize tracing/logger — secrets are never printed by design
|
||||||
@@ -28,7 +35,8 @@ fn main() {
|
|||||||
key,
|
key,
|
||||||
ca_cert,
|
ca_cert,
|
||||||
auth_token,
|
auth_token,
|
||||||
} => run_listen(listen, cert, key, ca_cert, auth_token),
|
auth_token_file,
|
||||||
|
} => run_listen(listen, cert, key, ca_cert, auth_token, auth_token_file),
|
||||||
crate::cli::Commands::Connect {
|
crate::cli::Commands::Connect {
|
||||||
target,
|
target,
|
||||||
socks,
|
socks,
|
||||||
@@ -36,7 +44,16 @@ fn main() {
|
|||||||
key,
|
key,
|
||||||
ca_cert,
|
ca_cert,
|
||||||
auth_token,
|
auth_token,
|
||||||
} => run_connect(target, socks, cert, key, ca_cert, auth_token),
|
auth_token_file,
|
||||||
|
} => run_connect(
|
||||||
|
target,
|
||||||
|
socks,
|
||||||
|
cert,
|
||||||
|
key,
|
||||||
|
ca_cert,
|
||||||
|
auth_token,
|
||||||
|
auth_token_file,
|
||||||
|
),
|
||||||
crate::cli::Commands::Generate {
|
crate::cli::Commands::Generate {
|
||||||
out,
|
out,
|
||||||
ca_name,
|
ca_name,
|
||||||
@@ -55,6 +72,7 @@ fn run_listen(
|
|||||||
key: String,
|
key: String,
|
||||||
ca_cert: String,
|
ca_cert: String,
|
||||||
auth_token: String,
|
auth_token: String,
|
||||||
|
auth_token_file: Option<String>,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
// Validate all inputs before opening any sockets
|
// Validate all inputs before opening any sockets
|
||||||
if cert.is_empty() {
|
if cert.is_empty() {
|
||||||
@@ -69,10 +87,6 @@ fn run_listen(
|
|||||||
eprintln!("Error: --ca-cert is required for listen command");
|
eprintln!("Error: --ca-cert is required for listen command");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if auth_token.is_empty() {
|
|
||||||
eprintln!("Error: --auth-token is required for listen command");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (host, port) = match cli::parse_host_port(&listen) {
|
let (host, port) = match cli::parse_host_port(&listen) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -96,6 +110,21 @@ fn run_listen(
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load auth token from file or value
|
||||||
|
let token_path: Option<&Path> = auth_token_file.as_deref().map(Path::new);
|
||||||
|
let token_value: Option<&str> = if auth_token.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&auth_token)
|
||||||
|
};
|
||||||
|
let auth_token = match tunnel::load_auth_token(token_value, token_path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport)",
|
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport)",
|
||||||
host,
|
host,
|
||||||
@@ -109,8 +138,24 @@ fn run_listen(
|
|||||||
redact::Redacted::new(&auth_token)
|
redact::Redacted::new(&auth_token)
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Implement HTTPS mTLS listener
|
let bind_addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
|
||||||
tracing::info!("Listener implementation pending (Milestone 2)");
|
let config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: Arc::from(Path::new(&cert)),
|
||||||
|
server_key_path: Arc::from(Path::new(&key)),
|
||||||
|
ca_cert_path: Arc::from(Path::new(&ca_cert)),
|
||||||
|
auth_token: Arc::new(auth_token),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the async listener
|
||||||
|
if let Err(e) = tokio::runtime::Runtime::new()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(tunnel::run_listener(config))
|
||||||
|
{
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +166,7 @@ fn run_connect(
|
|||||||
key: String,
|
key: String,
|
||||||
ca_cert: String,
|
ca_cert: String,
|
||||||
auth_token: String,
|
auth_token: String,
|
||||||
|
auth_token_file: Option<String>,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
// Validate all inputs before opening any sockets
|
// Validate all inputs before opening any sockets
|
||||||
if cert.is_empty() {
|
if cert.is_empty() {
|
||||||
@@ -135,12 +181,8 @@ fn run_connect(
|
|||||||
eprintln!("Error: --ca-cert is required for connect command");
|
eprintln!("Error: --ca-cert is required for connect command");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if auth_token.is_empty() {
|
|
||||||
eprintln!("Error: --auth-token is required for connect command");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_target_host, target_port) = match cli::parse_host_port(&target) {
|
let (target_host, target_port) = match cli::parse_host_port(&target) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error: {}", e);
|
eprintln!("Error: {}", e);
|
||||||
@@ -170,9 +212,24 @@ fn run_connect(
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load auth token from file or value
|
||||||
|
let token_path: Option<&Path> = auth_token_file.as_deref().map(Path::new);
|
||||||
|
let token_value: Option<&str> = if auth_token.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&auth_token)
|
||||||
|
};
|
||||||
|
let auth_token = match tunnel::load_auth_token(token_value, token_path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Connecting to HTTPS tunnel at {}:{} (HTTPS default transport)",
|
"Connecting to HTTPS tunnel at {}:{} (HTTPS default transport)",
|
||||||
_target_host,
|
target_host,
|
||||||
target_port
|
target_port
|
||||||
);
|
);
|
||||||
tracing::info!("SOCKS5 proxy will listen on {}:{} ", socks_host, socks_port);
|
tracing::info!("SOCKS5 proxy will listen on {}:{} ", socks_host, socks_port);
|
||||||
@@ -184,8 +241,24 @@ fn run_connect(
|
|||||||
redact::Redacted::new(&auth_token)
|
redact::Redacted::new(&auth_token)
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Implement HTTPS mTLS connector and SOCKS5 proxy
|
let target_addr: SocketAddr = format!("{}:{}", target_host, target_port).parse().unwrap();
|
||||||
tracing::info!("Connector and SOCKS5 implementation pending (Milestone 2-3)");
|
let config = ConnectorConfig {
|
||||||
|
target_addr,
|
||||||
|
client_cert_path: Arc::from(Path::new(&cert)),
|
||||||
|
client_key_path: Arc::from(Path::new(&key)),
|
||||||
|
ca_cert_path: Arc::from(Path::new(&ca_cert)),
|
||||||
|
auth_token: Arc::new(auth_token),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the async connector
|
||||||
|
if let Err(e) = tokio::runtime::Runtime::new()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(tunnel::connect_tunnel(config))
|
||||||
|
{
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
/// TLS configuration builders for the HTTPS mTLS tunnel.
|
||||||
|
///
|
||||||
|
/// Provides server and client TLS configurations using rustls with mTLS.
|
||||||
|
/// Both sides require valid certificates and fail closed on any configuration error.
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
|
||||||
|
use rustls::server::danger::ClientCertVerifier;
|
||||||
|
use rustls::{ClientConfig, RootCertStore, ServerConfig};
|
||||||
|
|
||||||
|
use crate::errors::TlsError;
|
||||||
|
|
||||||
|
/// Load PEM-encoded certificates from a file.
|
||||||
|
pub fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError> {
|
||||||
|
let content = std::fs::read(path).map_err(TlsError::Io)?;
|
||||||
|
let mut cursor = std::io::Cursor::new(&content);
|
||||||
|
let certs = rustls_pemfile::certs(&mut cursor)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| TlsError::Invalid("certificate file", e.to_string()))?;
|
||||||
|
if certs.is_empty() {
|
||||||
|
return Err(TlsError::Invalid(
|
||||||
|
"certificate file",
|
||||||
|
format!("no certificates found in {}", path.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(certs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a PEM-encoded private key from a file.
|
||||||
|
pub fn load_key(path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
|
||||||
|
let content = std::fs::read(path).map_err(TlsError::Io)?;
|
||||||
|
let mut cursor = std::io::Cursor::new(&content);
|
||||||
|
match rustls_pemfile::private_key(&mut cursor) {
|
||||||
|
Ok(Some(key)) => Ok(key),
|
||||||
|
Ok(None) => Err(TlsError::Invalid(
|
||||||
|
"private key file",
|
||||||
|
format!("no private key found in {}", path.display()),
|
||||||
|
)),
|
||||||
|
Err(e) => Err(TlsError::Invalid("private key file", e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Rustls server configuration with mTLS.
|
||||||
|
///
|
||||||
|
/// The server will:
|
||||||
|
/// - Present the given certificate and key
|
||||||
|
/// - Require client certificates signed by the given CA
|
||||||
|
/// - Fail closed if any configuration is invalid
|
||||||
|
pub fn build_server_config(
|
||||||
|
server_cert_path: &Path,
|
||||||
|
server_key_path: &Path,
|
||||||
|
ca_cert_path: &Path,
|
||||||
|
) -> Result<Arc<ServerConfig>, TlsError> {
|
||||||
|
// Load server certificate
|
||||||
|
let server_certs = load_certs(server_cert_path)?;
|
||||||
|
if server_certs.is_empty() {
|
||||||
|
return Err(TlsError::Missing("server certificate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load server private key
|
||||||
|
let server_key = load_key(server_key_path)?;
|
||||||
|
|
||||||
|
// Load CA certificate for client verification
|
||||||
|
let ca_certs = load_certs(ca_cert_path)?;
|
||||||
|
if ca_certs.is_empty() {
|
||||||
|
return Err(TlsError::Missing("CA certificate for client verification"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build root cert store for client verification
|
||||||
|
let mut root_store = RootCertStore::empty();
|
||||||
|
for ca_cert in &ca_certs {
|
||||||
|
root_store
|
||||||
|
.add(ca_cert.clone())
|
||||||
|
.map_err(|e| TlsError::Invalid("CA certificate", e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client verifier that requires valid client certs
|
||||||
|
let client_verifier: Arc<dyn ClientCertVerifier> =
|
||||||
|
rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| TlsError::Invalid("client verifier", e.to_string()))?;
|
||||||
|
|
||||||
|
// Build server config
|
||||||
|
let config = ServerConfig::builder()
|
||||||
|
.with_client_cert_verifier(client_verifier)
|
||||||
|
.with_single_cert(server_certs, server_key)
|
||||||
|
.map_err(|e| TlsError::Invalid("server config", e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Arc::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Rustls client configuration with mTLS.
|
||||||
|
///
|
||||||
|
/// The client will:
|
||||||
|
/// - Present the given certificate and key
|
||||||
|
/// - Verify the server certificate against the given CA
|
||||||
|
/// - Fail closed if any configuration is invalid
|
||||||
|
pub fn build_client_config(
|
||||||
|
client_cert_path: &Path,
|
||||||
|
client_key_path: &Path,
|
||||||
|
ca_cert_path: &Path,
|
||||||
|
) -> Result<Arc<ClientConfig>, TlsError> {
|
||||||
|
// Load client certificate
|
||||||
|
let client_certs = load_certs(client_cert_path)?;
|
||||||
|
if client_certs.is_empty() {
|
||||||
|
return Err(TlsError::Missing("client certificate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load client private key
|
||||||
|
let client_key = load_key(client_key_path)?;
|
||||||
|
|
||||||
|
// Load CA certificate for server verification
|
||||||
|
let ca_certs = load_certs(ca_cert_path)?;
|
||||||
|
if ca_certs.is_empty() {
|
||||||
|
return Err(TlsError::Missing("CA certificate for server verification"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build root cert store for server verification
|
||||||
|
let mut root_store = RootCertStore::empty();
|
||||||
|
for ca_cert in &ca_certs {
|
||||||
|
root_store
|
||||||
|
.add(ca_cert.clone())
|
||||||
|
.map_err(|e| TlsError::Invalid("CA certificate", e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build client config
|
||||||
|
let config = ClientConfig::builder()
|
||||||
|
.with_root_certificates(root_store)
|
||||||
|
.with_client_auth_cert(client_certs, client_key)
|
||||||
|
.map_err(|e| TlsError::Invalid("client config", e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Arc::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a ServerName from a hostname for SNI/verification.
|
||||||
|
pub fn server_name_from_host(host: &str) -> Result<ServerName<'static>, TlsError> {
|
||||||
|
ServerName::try_from(host.to_string())
|
||||||
|
.map_err(|_| TlsError::Invalid("hostname", format!("invalid hostname: {}", host)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a certificate's identity against a hostname.
|
||||||
|
/// Returns an error if the certificate doesn't match the expected hostname.
|
||||||
|
pub fn validate_cert_identity(
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
expected_host: &str,
|
||||||
|
) -> Result<(), TlsError> {
|
||||||
|
// Use x509-parser to parse the certificate and check SANs/CN
|
||||||
|
let (_, parsed) = x509_parser::parse_x509_certificate(cert)
|
||||||
|
.map_err(|e| TlsError::Invalid("certificate parsing", e.to_string()))?;
|
||||||
|
|
||||||
|
let subject_name = parsed.tbs_certificate.subject.to_string();
|
||||||
|
|
||||||
|
// Check Subject Alternative Names — use method call (x509-parser 0.16)
|
||||||
|
if let Ok(Some(alt_names)) = parsed.tbs_certificate.subject_alternative_name() {
|
||||||
|
for name in &alt_names.value.general_names {
|
||||||
|
match name {
|
||||||
|
x509_parser::extensions::GeneralName::DNSName(dn) if *dn == expected_host => {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
x509_parser::extensions::GeneralName::IPAddress(ip) => {
|
||||||
|
// ip is &[u8] (inner type of the enum variant matched through ref)
|
||||||
|
if ip.len() == 4 {
|
||||||
|
let addr = std::net::Ipv4Addr::new(ip[0], ip[1], ip[2], ip[3]);
|
||||||
|
if addr.to_string() == expected_host {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else if ip.len() == 16 {
|
||||||
|
let bytes: [u8; 16] = (*ip).try_into().unwrap();
|
||||||
|
let addr = std::net::Ipv6Addr::from(bytes);
|
||||||
|
if addr.to_string() == expected_host {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to CN check (less strict but still useful)
|
||||||
|
if subject_name.contains(expected_host) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(TlsError::IdentityMismatch(format!(
|
||||||
|
"certificate does not match hostname '{}'",
|
||||||
|
expected_host
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a certificate has expired.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn check_cert_not_expired(cert: &CertificateDer<'_>) -> Result<(), TlsError> {
|
||||||
|
let (_, parsed) = x509_parser::parse_x509_certificate(cert)
|
||||||
|
.map_err(|e| TlsError::Invalid("certificate parsing", e.to_string()))?;
|
||||||
|
|
||||||
|
let now = time::OffsetDateTime::now_utc();
|
||||||
|
|
||||||
|
let not_before = parsed.tbs_certificate.validity.not_before.to_datetime();
|
||||||
|
let not_after = parsed.tbs_certificate.validity.not_after.to_datetime();
|
||||||
|
|
||||||
|
if now < not_before {
|
||||||
|
return Err(TlsError::Invalid(
|
||||||
|
"certificate",
|
||||||
|
format!("certificate not yet valid (not before: {})", not_before),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if now > not_after {
|
||||||
|
return Err(TlsError::Expired(format!(
|
||||||
|
"certificate expired at {}",
|
||||||
|
not_after
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn init_crypto() {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_ca() -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let params = rcgen::CertificateParams::default();
|
||||||
|
let key = rcgen::KeyPair::generate().unwrap();
|
||||||
|
let key_pem = key.serialize_pem().into_bytes();
|
||||||
|
let key_der = key.serialize_der();
|
||||||
|
let issuer = rcgen::Issuer::new(params.clone(), key);
|
||||||
|
let pub_key = rcgen::KeyPair::try_from(key_der).unwrap();
|
||||||
|
let cert = params.signed_by(&pub_key, &issuer).unwrap();
|
||||||
|
|
||||||
|
(cert.pem().into_bytes(), key_pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_certs_from_file() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (cert_pem, _) = create_test_ca();
|
||||||
|
|
||||||
|
let cert_path = dir.path().join("test.pem");
|
||||||
|
std::fs::write(&cert_path, &cert_pem).unwrap();
|
||||||
|
|
||||||
|
let certs = load_certs(&cert_path).unwrap();
|
||||||
|
assert!(!certs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_certs_empty_fails() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cert_path = dir.path().join("empty.pem");
|
||||||
|
std::fs::write(&cert_path, "").unwrap();
|
||||||
|
|
||||||
|
let result = load_certs(&cert_path);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_certs_missing_fails() {
|
||||||
|
let path = Path::new("/nonexistent_rustunnel_cert.pem");
|
||||||
|
let result = load_certs(path);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_key_from_file() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
// Generate a key with rcgen and write its PKCS8 PEM
|
||||||
|
let key = rcgen::KeyPair::generate().unwrap();
|
||||||
|
let key_pem = key.serialize_pem();
|
||||||
|
|
||||||
|
let key_path = dir.path().join("test.key");
|
||||||
|
std::fs::write(&key_path, &key_pem).unwrap();
|
||||||
|
|
||||||
|
let key = load_key(&key_path).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
key,
|
||||||
|
PrivateKeyDer::Pkcs8(_) | PrivateKeyDer::Pkcs1(_) | PrivateKeyDer::Sec1(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_key_missing_fails() {
|
||||||
|
let path = Path::new("/nonexistent_rustunnel_key.pem");
|
||||||
|
let result = load_key(path);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_name_from_host_valid() {
|
||||||
|
let name = server_name_from_host("localhost").unwrap();
|
||||||
|
assert!(matches!(name, ServerName::DnsName(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_name_from_host_ip() {
|
||||||
|
let name = server_name_from_host("127.0.0.1").unwrap();
|
||||||
|
assert!(matches!(name, ServerName::IpAddress(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_cert_identity_matches_ip() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let out_dir = dir.path();
|
||||||
|
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
|
||||||
|
|
||||||
|
let _cert = std::fs::read(out_dir.join("server.crt")).unwrap();
|
||||||
|
let cert_der = load_certs(&out_dir.join("server.crt")).unwrap()[0].clone();
|
||||||
|
|
||||||
|
// Should match IP SAN
|
||||||
|
assert!(validate_cert_identity(&cert_der, "127.0.0.1").is_ok());
|
||||||
|
|
||||||
|
// Should match DNS SAN
|
||||||
|
assert!(validate_cert_identity(&cert_der, "localhost").is_ok());
|
||||||
|
|
||||||
|
// Should not match random host
|
||||||
|
assert!(validate_cert_identity(&cert_der, "example.com").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_cert_not_expired_with_valid_cert() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let out_dir = dir.path();
|
||||||
|
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
|
||||||
|
|
||||||
|
let cert_der = load_certs(&out_dir.join("server.crt")).unwrap()[0].clone();
|
||||||
|
assert!(check_cert_not_expired(&cert_der).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_client_config_valid() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let out_dir = dir.path();
|
||||||
|
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
|
||||||
|
|
||||||
|
let config = build_client_config(
|
||||||
|
&out_dir.join("client.crt"),
|
||||||
|
&out_dir.join("client.key"),
|
||||||
|
&out_dir.join("ca.pem"),
|
||||||
|
);
|
||||||
|
assert!(config.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_server_config_valid() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let out_dir = dir.path();
|
||||||
|
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
|
||||||
|
|
||||||
|
let config = build_server_config(
|
||||||
|
&out_dir.join("server.crt"),
|
||||||
|
&out_dir.join("server.key"),
|
||||||
|
&out_dir.join("ca.pem"),
|
||||||
|
);
|
||||||
|
assert!(config.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_client_config_missing_cert_fails() {
|
||||||
|
init_crypto();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let out_dir = dir.path();
|
||||||
|
crate::generate::generate(out_dir, "test-ca", "test-server", "test-client").unwrap();
|
||||||
|
|
||||||
|
let config = build_client_config(
|
||||||
|
&out_dir.join("nonexistent.crt"),
|
||||||
|
&out_dir.join("client.key"),
|
||||||
|
&out_dir.join("ca.pem"),
|
||||||
|
);
|
||||||
|
assert!(config.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+851
@@ -0,0 +1,851 @@
|
|||||||
|
/// HTTPS mTLS tunnel with application-level authentication.
|
||||||
|
///
|
||||||
|
/// Provides a listener and connector that establish a secure tunnel over raw TCP
|
||||||
|
/// with mutual TLS and an additional auth/session token handshake.
|
||||||
|
///
|
||||||
|
/// Protocol:
|
||||||
|
/// 1. TCP connect
|
||||||
|
/// 2. TLS handshake (rustls mTLS)
|
||||||
|
/// 3. Line-based auth: Client sends "AUTH <token>\n", Server responds "OK\n" or "FAIL <reason>\n"
|
||||||
|
///
|
||||||
|
/// Security gates (all must pass before forwarding):
|
||||||
|
/// 1. TLS negotiation
|
||||||
|
/// 2. Server certificate validation
|
||||||
|
/// 3. Client certificate validation
|
||||||
|
/// 4. Application auth token validation
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader as AsyncBufReader};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
|
use crate::errors::{AuthError, TunnelError};
|
||||||
|
use crate::redact::Redacted;
|
||||||
|
use crate::tls;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth token handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Load an auth token from a file or use the provided value.
|
||||||
|
pub fn load_auth_token(
|
||||||
|
token_value: Option<&str>,
|
||||||
|
token_path: Option<&Path>,
|
||||||
|
) -> Result<String, AuthError> {
|
||||||
|
if let Some(path) = token_path {
|
||||||
|
let content = std::fs::read_to_string(path).map_err(|_| AuthError::Missing)?;
|
||||||
|
let trimmed = content.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AuthError::Missing);
|
||||||
|
}
|
||||||
|
Ok(trimmed)
|
||||||
|
} else if let Some(t) = token_value {
|
||||||
|
if t.is_empty() {
|
||||||
|
return Err(AuthError::Missing);
|
||||||
|
}
|
||||||
|
Ok(t.to_string())
|
||||||
|
} else {
|
||||||
|
Err(AuthError::Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constant-time comparison
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Constant-time string comparison to prevent timing attacks.
|
||||||
|
fn constant_time_compare(a: &str, b: &str) -> bool {
|
||||||
|
let a_bytes = a.as_bytes();
|
||||||
|
let b_bytes = b.as_bytes();
|
||||||
|
if a_bytes.len() != b_bytes.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut result: u8 = 0;
|
||||||
|
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
|
||||||
|
result |= x ^ y;
|
||||||
|
}
|
||||||
|
result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth handshake
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Perform the application-level auth handshake over a TLS stream.
|
||||||
|
///
|
||||||
|
/// Protocol:
|
||||||
|
/// - Client sends: AUTH <token>\n
|
||||||
|
/// - Server responds: OK\n on success, or FAIL <reason>\n on failure
|
||||||
|
///
|
||||||
|
/// Returns Ok(()) on successful auth, Err on failure.
|
||||||
|
async fn perform_auth_handshake(
|
||||||
|
mut stream: impl tokio::io::AsyncReadExt + tokio::io::AsyncWriteExt + Unpin,
|
||||||
|
expected_token: &str,
|
||||||
|
is_server: bool,
|
||||||
|
) -> Result<(), TunnelError> {
|
||||||
|
if is_server {
|
||||||
|
// Server: read AUTH line and validate
|
||||||
|
let mut buf_reader = AsyncBufReader::new(&mut stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
let bytes_read = buf_reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.map_err(TunnelError::Io)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
let _ = stream.write_all(b"FAIL connection closed\n").await;
|
||||||
|
return Err(TunnelError::Protocol(
|
||||||
|
"client closed connection during auth".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_trimmed = line.trim();
|
||||||
|
if !line_trimmed.starts_with("AUTH ") {
|
||||||
|
let _ = stream.write_all(b"FAIL invalid auth format\n").await;
|
||||||
|
return Err(TunnelError::Auth(AuthError::Invalid));
|
||||||
|
}
|
||||||
|
let received_token = line_trimmed["AUTH ".len()..].trim().to_string();
|
||||||
|
// Constant-time comparison to avoid timing attacks
|
||||||
|
let matches = constant_time_compare(&received_token, expected_token);
|
||||||
|
if !matches {
|
||||||
|
let _ = stream.write_all(b"FAIL invalid token\n").await;
|
||||||
|
return Err(TunnelError::Auth(AuthError::Invalid));
|
||||||
|
}
|
||||||
|
let _ = stream.write_all(b"OK\n").await;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// Client: send AUTH and read response
|
||||||
|
let auth_line = format!("AUTH {}\n", expected_token);
|
||||||
|
stream
|
||||||
|
.write_all(auth_line.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(TunnelError::Io)?;
|
||||||
|
|
||||||
|
let mut buf_reader = AsyncBufReader::new(&mut stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
let bytes_read = buf_reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.map_err(TunnelError::Io)?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
return Err(TunnelError::ConnectionClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_trimmed = line.trim();
|
||||||
|
if line_trimmed == "OK" {
|
||||||
|
Ok(())
|
||||||
|
} else if let Some(_reason) = line_trimmed.strip_prefix("FAIL ") {
|
||||||
|
Err(TunnelError::Auth(AuthError::Invalid))
|
||||||
|
} else {
|
||||||
|
Err(TunnelError::Protocol(format!(
|
||||||
|
"unexpected auth response: {}",
|
||||||
|
&line_trimmed[..line_trimmed.len().min(64)]
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Listener
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Configuration for the tunnel listener.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ListenerConfig {
|
||||||
|
pub bind_addr: SocketAddr,
|
||||||
|
pub server_cert_path: Arc<Path>,
|
||||||
|
pub server_key_path: Arc<Path>,
|
||||||
|
pub ca_cert_path: Arc<Path>,
|
||||||
|
pub auth_token: Arc<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the HTTPS tunnel listener.
|
||||||
|
///
|
||||||
|
/// Binds a TCP listener on the configured address and accepts incoming
|
||||||
|
/// tunnel connections over TLS with mTLS and auth. Returns when the
|
||||||
|
/// listener is shut down or an error occurs.
|
||||||
|
pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> {
|
||||||
|
let addr = config.bind_addr;
|
||||||
|
let server_cert_path = config.server_cert_path.clone();
|
||||||
|
let server_key_path = config.server_key_path.clone();
|
||||||
|
let ca_cert_path = config.ca_cert_path.clone();
|
||||||
|
let auth_token = config.auth_token.clone();
|
||||||
|
|
||||||
|
// Build TLS config — validates all cert/key files before opening sockets
|
||||||
|
let tls_config = tls::build_server_config(
|
||||||
|
server_cert_path.as_ref(),
|
||||||
|
server_key_path.as_ref(),
|
||||||
|
ca_cert_path.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to build TLS config: {}", e);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Validate server cert identity matches bind address
|
||||||
|
let server_certs = tls::load_certs(server_cert_path.as_ref()).map_err(TunnelError::Tls)?;
|
||||||
|
let bind_host = addr.ip().to_string();
|
||||||
|
tls::validate_cert_identity(&server_certs[0], &bind_host).map_err(|e| {
|
||||||
|
tracing::error!("Server cert identity mismatch: {}", e);
|
||||||
|
TunnelError::Tls(crate::errors::TlsError::IdentityMismatch(format!(
|
||||||
|
"server cert does not match bind address {}: {}",
|
||||||
|
bind_host, e
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let acceptor = TlsAcceptor::from(tls_config);
|
||||||
|
|
||||||
|
// Bind TCP listener — fail closed on port conflict
|
||||||
|
let listener = TcpListener::bind(addr).await.map_err(|e| {
|
||||||
|
let addr_str = addr.to_string();
|
||||||
|
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||||
|
TunnelError::PortInUse(addr.port())
|
||||||
|
} else {
|
||||||
|
TunnelError::BindError(addr_str, e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"HTTPS tunnel listener bound to {} (HTTPS default transport)",
|
||||||
|
addr
|
||||||
|
);
|
||||||
|
tracing::info!("Auth token configured: {}", Redacted::new(&*auth_token));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer_addr) = match listener.accept().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Accept error: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("New connection from {}", peer_addr);
|
||||||
|
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) =
|
||||||
|
handle_listener_connection(stream, peer_addr, acceptor, auth_token).await
|
||||||
|
{
|
||||||
|
tracing::warn!("Connection from {} failed: {}", peer_addr, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single incoming TLS connection for the listener.
|
||||||
|
async fn handle_listener_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
acceptor: TlsAcceptor,
|
||||||
|
auth_token: Arc<String>,
|
||||||
|
) -> Result<(), TunnelError> {
|
||||||
|
// Perform TLS accept
|
||||||
|
let tls_stream = acceptor.accept(stream).await.map_err(|e| {
|
||||||
|
tracing::warn!("TLS accept failed for {}: {}", peer_addr, e);
|
||||||
|
TunnelError::Tls(crate::errors::TlsError::VerificationFailed(e.to_string()))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Check peer certificates exist
|
||||||
|
// get_ref() returns (&TcpStream, &ServerConnection)
|
||||||
|
// ServerConnection has peer_certificates() -> Option<&[CertificateDer]>
|
||||||
|
let (_tcp_stream, server_conn) = tls_stream.get_ref();
|
||||||
|
let peer_certs = server_conn.peer_certificates().ok_or_else(|| {
|
||||||
|
tracing::warn!("No client certificate from {}", peer_addr);
|
||||||
|
TunnelError::Tls(crate::errors::TlsError::Missing("client certificate"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if peer_certs.is_empty() {
|
||||||
|
tracing::warn!("Empty client certificate chain from {}", peer_addr);
|
||||||
|
return Err(TunnelError::Tls(crate::errors::TlsError::Missing(
|
||||||
|
"client certificate",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("mTLS established with {}", peer_addr);
|
||||||
|
|
||||||
|
// Perform application auth handshake over the TLS stream
|
||||||
|
perform_auth_handshake(tls_stream, &auth_token, true).await?;
|
||||||
|
|
||||||
|
tracing::info!("Tunnel established with {}", peer_addr);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Configuration for the tunnel connector.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConnectorConfig {
|
||||||
|
pub target_addr: SocketAddr,
|
||||||
|
pub client_cert_path: Arc<Path>,
|
||||||
|
pub client_key_path: Arc<Path>,
|
||||||
|
pub ca_cert_path: Arc<Path>,
|
||||||
|
pub auth_token: Arc<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to the HTTPS tunnel and establish a session.
|
||||||
|
///
|
||||||
|
/// Performs the full security gate sequence:
|
||||||
|
/// 1. TCP connection
|
||||||
|
/// 2. TLS handshake with server validation
|
||||||
|
/// 3. mTLS client certificate presentation
|
||||||
|
/// 4. Application auth token validation
|
||||||
|
pub async fn connect_tunnel(config: ConnectorConfig) -> Result<(), TunnelError> {
|
||||||
|
let target_addr = config.target_addr;
|
||||||
|
let client_cert_path = config.client_cert_path.clone();
|
||||||
|
let client_key_path = config.client_key_path.clone();
|
||||||
|
let ca_cert_path = config.ca_cert_path.clone();
|
||||||
|
let auth_token = config.auth_token.clone();
|
||||||
|
|
||||||
|
// Build client TLS config
|
||||||
|
let client_config = tls::build_client_config(
|
||||||
|
client_cert_path.as_ref(),
|
||||||
|
client_key_path.as_ref(),
|
||||||
|
ca_cert_path.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to build client TLS config: {}", e);
|
||||||
|
TunnelError::Tls(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let connector = tokio_rustls::TlsConnector::from(client_config);
|
||||||
|
|
||||||
|
// Create server name for SNI
|
||||||
|
let server_name = tls::server_name_from_host(&target_addr.ip().to_string()).map_err(|e| {
|
||||||
|
tracing::error!("Invalid server name: {}", e);
|
||||||
|
TunnelError::Tls(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// TCP connect
|
||||||
|
let stream = match TcpStream::connect(target_addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::ConnectionRefused {
|
||||||
|
return Err(TunnelError::ConnectionRefused(target_addr.to_string()));
|
||||||
|
}
|
||||||
|
tracing::error!("Connection failed: {}", e);
|
||||||
|
return Err(TunnelError::Io(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Connected to {} at {}", "HTTPS tunnel", target_addr);
|
||||||
|
|
||||||
|
// TLS handshake with server validation
|
||||||
|
let tls_stream = match connector.connect(server_name, stream).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("TLS handshake failed: {}", e);
|
||||||
|
return Err(TunnelError::Tls(
|
||||||
|
crate::errors::TlsError::VerificationFailed(format!(
|
||||||
|
"TLS handshake to {} failed: {}",
|
||||||
|
target_addr, e
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("TLS handshake completed with {}", target_addr);
|
||||||
|
|
||||||
|
// Perform application auth handshake
|
||||||
|
match perform_auth_handshake(tls_stream, &auth_token, false).await {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!("Tunnel session established with {}", target_addr);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(TunnelError::Auth(e)) => {
|
||||||
|
tracing::error!("Auth failed: {}", e);
|
||||||
|
Err(TunnelError::Auth(e))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Auth handshake error: {}", e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to reconnect to the tunnel, revalidating all security gates.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn reconnect_tunnel(config: &ConnectorConfig) -> Result<(), TunnelError> {
|
||||||
|
tracing::info!(
|
||||||
|
"Attempting reconnect to {} — revalidating security gates",
|
||||||
|
config.target_addr
|
||||||
|
);
|
||||||
|
connect_tunnel(config.clone()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Security gate state machine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Check if all security gates have passed for a connection.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum SecurityGateStatus {
|
||||||
|
/// TLS not yet negotiated
|
||||||
|
TlsPending,
|
||||||
|
/// TLS negotiated, client cert check pending
|
||||||
|
ClientCertPending,
|
||||||
|
/// mTLS established, auth pending
|
||||||
|
AuthPending,
|
||||||
|
/// All gates passed, tunnel ready
|
||||||
|
Authenticated,
|
||||||
|
/// A gate failed, tunnel rejected
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State machine for security gate tracking.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SecurityGateState {
|
||||||
|
status: Arc<Mutex<SecurityGateStatus>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl SecurityGateState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
status: Arc::new(Mutex::new(SecurityGateStatus::TlsPending)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn advance(&self, to: SecurityGateStatus) {
|
||||||
|
let mut status = self.status.lock().await;
|
||||||
|
*status = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_authenticated(&self) -> bool {
|
||||||
|
let status = self.status.lock().await;
|
||||||
|
*status == SecurityGateStatus::Authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_status(&self) -> SecurityGateStatus {
|
||||||
|
*self.status.lock().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU16, Ordering};
|
||||||
|
|
||||||
|
static PORT_COUNTER: AtomicU16 = AtomicU16::new(50000);
|
||||||
|
|
||||||
|
fn get_free_port() -> u16 {
|
||||||
|
PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_test_creds() -> tempfile::TempDir {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
crate::generate::generate(dir.path(), "test-ca", "test-server", "test-client").unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! path_arc {
|
||||||
|
($e:expr) => {
|
||||||
|
Arc::from(std::path::Path::new(&$e))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn valid_mtls_and_auth_establishes_tunnel() {
|
||||||
|
let dir = generate_test_creds();
|
||||||
|
let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new(token.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
// Give the listener time to bind
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new(token.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = connect_tunnel(connector_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Valid mTLS+auth should establish tunnel: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_auth_token_fails() {
|
||||||
|
let dir = generate_test_creds();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("correct-token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("wrong-token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = connect_tunnel(connector_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Invalid auth token should fail: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_client_cert_fails() {
|
||||||
|
let dir1 = generate_test_creds();
|
||||||
|
let dir2 = generate_test_creds();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir1.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir1.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir1.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
// Client from a different CA — should fail at TLS
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir2.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir2.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir2.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = connect_tunnel(connector_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Different CA client cert should fail: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn port_conflict_fails_without_fallback() {
|
||||||
|
let dir = generate_test_creds();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
// Bind the port first
|
||||||
|
let _listener = TcpListener::bind(bind_addr).await.unwrap();
|
||||||
|
|
||||||
|
let config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = run_listener(config).await;
|
||||||
|
assert!(matches!(result, Err(TunnelError::PortInUse(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_tls_config_fails_closed() {
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: Arc::from(std::path::Path::new("/nonexistent/cert.pem")),
|
||||||
|
server_key_path: Arc::from(std::path::Path::new("/nonexistent/key.pem")),
|
||||||
|
ca_cert_path: Arc::from(std::path::Path::new("/nonexistent/ca.pem")),
|
||||||
|
auth_token: Arc::new("token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = run_listener(config).await;
|
||||||
|
assert!(result.is_err(), "Missing TLS config should fail closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_auth_token_from_value() {
|
||||||
|
let token = load_auth_token(Some("my-token"), None).unwrap();
|
||||||
|
assert_eq!(token, "my-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_auth_token_from_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let token_path = dir.path().join("token.txt");
|
||||||
|
std::fs::write(&token_path, " file-token \n").unwrap();
|
||||||
|
|
||||||
|
let token = load_auth_token(None, Some(&token_path)).unwrap();
|
||||||
|
assert_eq!(token, "file-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_auth_token_empty_value_fails() {
|
||||||
|
let result = load_auth_token(Some(""), None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_time_compare_equal() {
|
||||||
|
assert!(constant_time_compare("abc", "abc"));
|
||||||
|
assert!(constant_time_compare("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_time_compare_not_equal() {
|
||||||
|
assert!(!constant_time_compare("abc", "abd"));
|
||||||
|
assert!(!constant_time_compare("abc", "abcd"));
|
||||||
|
assert!(!constant_time_compare("abc", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn security_gate_state_transitions() {
|
||||||
|
let state = SecurityGateState::new();
|
||||||
|
assert!(!state.is_authenticated().await);
|
||||||
|
assert_eq!(state.get_status().await, SecurityGateStatus::TlsPending);
|
||||||
|
|
||||||
|
state.advance(SecurityGateStatus::Authenticated).await;
|
||||||
|
assert!(state.is_authenticated().await);
|
||||||
|
assert_eq!(state.get_status().await, SecurityGateStatus::Authenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconnect_revalidates_security_gates() {
|
||||||
|
let dir = generate_test_creds();
|
||||||
|
let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new(token.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new(token.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// First connect succeeds
|
||||||
|
let result = connect_tunnel(connector_config.clone()).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Reconnect with wrong token should fail
|
||||||
|
let bad_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("bad-token".to_string()),
|
||||||
|
};
|
||||||
|
let result = reconnect_tunnel(&bad_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Reconnect with bad auth should fail: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reconnect with correct token should succeed
|
||||||
|
let result = reconnect_tunnel(&connector_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Reconnect with valid auth should succeed: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn https_is_default_carrier() {
|
||||||
|
// Verify that the listener binds and only accepts HTTPS (not plain HTTP)
|
||||||
|
let dir = generate_test_creds();
|
||||||
|
let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new(token.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
// Try plain TCP connection without TLS — should fail at TLS
|
||||||
|
let stream_result = TcpStream::connect(bind_addr).await;
|
||||||
|
if stream_result.is_ok() {
|
||||||
|
// TCP connects but the server expects TLS, so a plain TCP client
|
||||||
|
// won't get meaningful data — the server will fail on TLS accept
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify HTTPS connection works
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new(token.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = connect_tunnel(connector_config).await;
|
||||||
|
assert!(result.is_ok(), "HTTPS mTLS should work");
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn forwarding_starts_only_after_all_gates() {
|
||||||
|
// This test verifies the security gate state machine
|
||||||
|
let state = SecurityGateState::new();
|
||||||
|
|
||||||
|
// Before any gate passes, forwarding should not be allowed
|
||||||
|
assert!(!state.is_authenticated().await);
|
||||||
|
|
||||||
|
// Advance through gates
|
||||||
|
state.advance(SecurityGateStatus::ClientCertPending).await;
|
||||||
|
assert!(!state.is_authenticated().await);
|
||||||
|
|
||||||
|
state.advance(SecurityGateStatus::AuthPending).await;
|
||||||
|
assert!(!state.is_authenticated().await);
|
||||||
|
|
||||||
|
state.advance(SecurityGateStatus::Authenticated).await;
|
||||||
|
assert!(state.is_authenticated().await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_required_in_addition_to_mtls() {
|
||||||
|
// Valid mTLS + wrong auth = fail
|
||||||
|
let dir = generate_test_creds();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("correct-token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
// Correct mTLS, wrong auth
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("wrong-token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = connect_tunnel(connector_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Valid mTLS + wrong auth should fail: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_server_cert_rejected_by_client() {
|
||||||
|
// Client should reject server certs not signed by the expected CA
|
||||||
|
let dir1 = generate_test_creds();
|
||||||
|
let dir2 = generate_test_creds();
|
||||||
|
let port = get_free_port();
|
||||||
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
|
// Listener uses CA1 certs
|
||||||
|
let listener_config = ListenerConfig {
|
||||||
|
bind_addr,
|
||||||
|
server_cert_path: path_arc!(dir1.path().join("server.crt")),
|
||||||
|
server_key_path: path_arc!(dir1.path().join("server.key")),
|
||||||
|
ca_cert_path: path_arc!(dir1.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
// Client uses CA2 to validate server — should fail
|
||||||
|
let connector_config = ConnectorConfig {
|
||||||
|
target_addr: bind_addr,
|
||||||
|
client_cert_path: path_arc!(dir2.path().join("client.crt")),
|
||||||
|
client_key_path: path_arc!(dir2.path().join("client.key")),
|
||||||
|
ca_cert_path: path_arc!(dir2.path().join("ca.pem")),
|
||||||
|
auth_token: Arc::new("token".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = connect_tunnel(connector_config).await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"Client should reject server cert not signed by expected CA: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
listener_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user