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:
c4ch3c4d3
2026-06-03 18:29:15 -06:00
parent 03dab7c2c8
commit 393100de5a
7 changed files with 1955 additions and 19 deletions
Generated
+494 -1
View File
@@ -134,12 +134,40 @@ dependencies = [
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.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]]
name = "base64"
version = "0.22.1"
@@ -161,6 +189,15 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bytes"
version = "1.11.1"
@@ -174,6 +211,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -223,12 +262,56 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "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]]
name = "data-encoding"
version = "2.11.0"
@@ -272,6 +355,16 @@ dependencies = [
"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]]
name = "displaydoc"
version = "0.2.6"
@@ -283,6 +376,12 @@ dependencies = [
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -311,12 +410,72 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "getrandom"
version = "0.2.17"
@@ -328,6 +487,18 @@ dependencies = [
"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]]
name = "getrandom"
version = "0.4.2"
@@ -336,11 +507,30 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"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]]
name = "hashbrown"
version = "0.15.5"
@@ -368,6 +558,99 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "id-arena"
version = "2.3.0"
@@ -386,6 +669,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -398,6 +687,16 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "lazy_static"
version = "1.5.0"
@@ -585,6 +884,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -634,6 +939,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -746,6 +1057,31 @@ dependencies = [
"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]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -755,21 +1091,43 @@ dependencies = [
"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]]
name = "rustunnel"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"clap",
"hex",
"http-body-util",
"hyper",
"hyper-util",
"rand",
"rcgen",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-rustls",
"tower-service",
"tracing",
"tracing-subscriber",
"x509-parser 0.16.0",
@@ -830,6 +1188,17 @@ dependencies = [
"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]]
name = "sharded-slab"
version = "0.1.7"
@@ -855,6 +1224,12 @@ dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -877,6 +1252,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -899,6 +1280,27 @@ dependencies = [
"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]]
name = "tempfile"
version = "3.27.0"
@@ -1020,6 +1422,41 @@ dependencies = [
"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]]
name = "tracing"
version = "0.1.44"
@@ -1081,6 +1518,18 @@ dependencies = [
"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]]
name = "unicode-ident"
version = "1.0.24"
@@ -1111,6 +1560,21 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasi"
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"
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]]
name = "windows-sys"
version = "0.52.0"
+10
View File
@@ -19,6 +19,16 @@ x509-parser = "0.16"
time = { version = "0.3", features = ["formatting"] }
rand = "0.8"
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]
tempfile = "3"
+10 -2
View File
@@ -33,8 +33,12 @@ pub enum Commands {
ca_cert: String,
/// Auth/session token required from connecting clients
#[arg(long, value_name = "TOKEN")]
#[arg(long, default_value = "", value_name = "TOKEN")]
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
@@ -64,8 +68,12 @@ pub enum Commands {
ca_cert: String,
/// Auth/session token to authenticate with the listener
#[arg(long, value_name = "TOKEN")]
#[arg(long, default_value = "", value_name = "TOKEN")]
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
+118
View File
@@ -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
View File
@@ -1,13 +1,20 @@
mod cli;
mod config;
mod errors;
mod generate;
mod redact;
mod tls;
mod tunnel;
use std::net::SocketAddr;
use std::path::Path;
use std::process;
use std::sync::Arc;
use clap::Parser;
use crate::cli::Cli;
use crate::tunnel::{ConnectorConfig, ListenerConfig};
fn main() {
// Initialize tracing/logger — secrets are never printed by design
@@ -28,7 +35,8 @@ fn main() {
key,
ca_cert,
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 {
target,
socks,
@@ -36,7 +44,16 @@ fn main() {
key,
ca_cert,
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 {
out,
ca_name,
@@ -55,6 +72,7 @@ fn run_listen(
key: String,
ca_cert: String,
auth_token: String,
auth_token_file: Option<String>,
) -> i32 {
// Validate all inputs before opening any sockets
if cert.is_empty() {
@@ -69,10 +87,6 @@ fn run_listen(
eprintln!("Error: --ca-cert is required for listen command");
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) {
Ok(v) => v,
@@ -96,6 +110,21 @@ fn run_listen(
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!(
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport)",
host,
@@ -109,8 +138,24 @@ fn run_listen(
redact::Redacted::new(&auth_token)
);
// TODO: Implement HTTPS mTLS listener
tracing::info!("Listener implementation pending (Milestone 2)");
let bind_addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
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
}
@@ -121,6 +166,7 @@ fn run_connect(
key: String,
ca_cert: String,
auth_token: String,
auth_token_file: Option<String>,
) -> i32 {
// Validate all inputs before opening any sockets
if cert.is_empty() {
@@ -135,12 +181,8 @@ fn run_connect(
eprintln!("Error: --ca-cert is required for connect command");
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,
Err(e) => {
eprintln!("Error: {}", e);
@@ -170,9 +212,24 @@ fn run_connect(
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!(
"Connecting to HTTPS tunnel at {}:{} (HTTPS default transport)",
_target_host,
target_host,
target_port
);
tracing::info!("SOCKS5 proxy will listen on {}:{} ", socks_host, socks_port);
@@ -184,8 +241,24 @@ fn run_connect(
redact::Redacted::new(&auth_token)
);
// TODO: Implement HTTPS mTLS connector and SOCKS5 proxy
tracing::info!("Connector and SOCKS5 implementation pending (Milestone 2-3)");
let target_addr: SocketAddr = format!("{}:{}", target_host, target_port).parse().unwrap();
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
}
+383
View File
@@ -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
View File
@@ -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();
}
}