diff --git a/Cargo.lock b/Cargo.lock index 7617970..47585b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 420e253..6c6c35d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/cli.rs b/src/cli.rs index f0104c4..409998c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, }, /// 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, }, /// Generate local certificate and auth material diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..be2c458 --- /dev/null +++ b/src/errors.rs @@ -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), +} diff --git a/src/main.rs b/src/main.rs index af516b6..97246d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, ) -> 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, ) -> 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 } diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..43efbc3 --- /dev/null +++ b/src/tls.rs @@ -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>, 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::, _>>() + .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, 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, 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 = + 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, 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, 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, Vec) { + 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()); + } +} diff --git a/src/tunnel.rs b/src/tunnel.rs new file mode 100644 index 0000000..e12e9f1 --- /dev/null +++ b/src/tunnel.rs @@ -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 \n", Server responds "OK\n" or "FAIL \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 { + 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 \n +/// - Server responds: OK\n on success, or FAIL \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, + pub server_key_path: Arc, + pub ca_cert_path: Arc, + pub auth_token: Arc, +} + +/// 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, +) -> 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, + pub client_key_path: Arc, + pub ca_cert_path: Arc, + pub auth_token: Arc, +} + +/// 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>, +} + +#[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(); + } +}