From 9e0950daeba8d0d2d8fab69f5f2147629555cf35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 16 Jun 2026 15:55:06 +0200 Subject: [PATCH 01/16] add new dependencies and qr_file option --- src-tauri/Cargo.lock | 489 +++++++++++++++++++ src-tauri/Cargo.toml | 4 + src-tauri/client-cli/Cargo.toml | 5 + src-tauri/client-cli/src/cli.rs | 5 + src-tauri/client-cli/src/commands/connect.rs | 1 + src-tauri/client-cli/src/main.rs | 2 + 6 files changed, 506 insertions(+) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4b10f46b..b3524c7c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -38,6 +38,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -141,6 +159,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arboard" version = "3.6.1" @@ -162,12 +186,32 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ashpd" version = "0.10.3" @@ -499,6 +543,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -606,6 +693,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -621,6 +714,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -736,6 +838,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -1028,6 +1136,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1200,6 +1314,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1381,6 +1514,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -1407,7 +1546,11 @@ dependencies = [ "defguard-client-core", "defguard-client-posture", "defguard-client-proto", + "futures-util", + "http", + "image", "owo-colors", + "qrcode", "reqwest 0.13.4", "secrecy", "serde", @@ -1417,6 +1560,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-tungstenite", "tonic", "tracing", "tracing-subscriber", @@ -2074,6 +2218,26 @@ dependencies = [ "regex", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2145,6 +2309,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2585,6 +2764,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -3195,12 +3384,38 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "1.9.3" @@ -3242,6 +3457,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ip_network" version = "0.4.1" @@ -3511,6 +3737,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -3550,6 +3782,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libgit2-sys" version = "0.18.5+1.9.4" @@ -3660,6 +3902,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3704,6 +3955,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -3929,6 +4190,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -3948,6 +4218,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify-rust" version = "4.17.0" @@ -3980,6 +4256,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4002,6 +4288,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -4022,6 +4319,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4882,6 +5190,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "prost" version = "0.14.4" @@ -4997,6 +5324,24 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -5163,12 +5508,82 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5385,6 +5800,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -5997,6 +6418,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -7288,6 +7718,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -7668,6 +8112,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7987,6 +8450,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -9374,6 +9848,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.3" @@ -9564,6 +10044,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 73a82f56..59a1bb2a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,7 +32,11 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } webbrowser = "1.0" tempfile = "3" +futures-util = "0.3" http = "1" +image = "0.25" +qrcode = { version = "0.14", features = ["image"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } vergen-git2 = { version = "9.1", features = ["build"] } [workspace.package] diff --git a/src-tauri/client-cli/Cargo.toml b/src-tauri/client-cli/Cargo.toml index 8350ebfb..e4a1245d 100644 --- a/src-tauri/client-cli/Cargo.toml +++ b/src-tauri/client-cli/Cargo.toml @@ -29,6 +29,11 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } webbrowser.workspace = true +http.workspace = true +qrcode.workspace = true +image.workspace = true +tokio-tungstenite.workspace = true +futures-util.workspace = true # Dummy feature to let tauri build the release. [features] diff --git a/src-tauri/client-cli/src/cli.rs b/src-tauri/client-cli/src/cli.rs index b6f3ed2e..0a52ab8a 100644 --- a/src-tauri/client-cli/src/cli.rs +++ b/src-tauri/client-cli/src/cli.rs @@ -61,6 +61,11 @@ pub enum Commands { #[arg(long)] mfa_method: Option, + /// Save the mobile-approve MFA QR code as a PNG image to this path. + /// Required when stderr is not a terminal. + #[arg(long)] + qr_file: Option, + /// Override route-all-traffic for this connection only. #[arg(long, overrides_with = "predefined_traffic")] all_traffic: bool, diff --git a/src-tauri/client-cli/src/commands/connect.rs b/src-tauri/client-cli/src/commands/connect.rs index 118a3695..2dfdb2b1 100644 --- a/src-tauri/client-cli/src/commands/connect.rs +++ b/src-tauri/client-cli/src/commands/connect.rs @@ -30,6 +30,7 @@ pub async fn handle( code: Option<&str>, code_command: Option<&str>, mfa_method: Option<&str>, + _qr_file: Option<&str>, all_traffic: bool, predefined_traffic: bool, json: bool, diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 022afe89..bf58d408 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -62,6 +62,7 @@ async fn main() -> ExitCode { code, code_command, mfa_method, + qr_file, all_traffic, predefined_traffic, } => output::finish( @@ -74,6 +75,7 @@ async fn main() -> ExitCode { code.as_deref(), code_command.as_deref(), mfa_method.as_deref(), + qr_file.as_deref(), all_traffic, predefined_traffic, cli.json, From 09c5d22ed79ad6ca8d6f5eafa3313829896070f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 16 Jun 2026 17:51:45 +0200 Subject: [PATCH 02/16] add qr rendering helper --- src-tauri/Cargo.lock | 1 + src-tauri/client-cli/Cargo.toml | 1 + src-tauri/client-cli/src/main.rs | 1 + src-tauri/client-cli/src/mfa_qr.rs | 112 +++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 src-tauri/client-cli/src/mfa_qr.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b3524c7c..a0240d22 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1541,6 +1541,7 @@ dependencies = [ name = "defguard-cli" version = "2.1.0" dependencies = [ + "base64 0.22.1", "clap", "defguard-client-common", "defguard-client-core", diff --git a/src-tauri/client-cli/Cargo.toml b/src-tauri/client-cli/Cargo.toml index e4a1245d..ffd46ff9 100644 --- a/src-tauri/client-cli/Cargo.toml +++ b/src-tauri/client-cli/Cargo.toml @@ -19,6 +19,7 @@ common = { package = "defguard-client-common", path = "../common" } defguard_core = { package = "defguard-client-core", path = "../core" } defguard_client_posture = { package = "defguard-client-posture", path = "../enterprise/posture" } defguard_client_proto = { package = "defguard-client-proto", path = "../client-proto" } +base64.workspace = true reqwest.workspace = true secrecy.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index bf58d408..50a92b53 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -10,6 +10,7 @@ mod exit; mod logging; mod mfa; mod mfa_code; +mod mfa_qr; mod output; mod resolve; mod state; diff --git a/src-tauri/client-cli/src/mfa_qr.rs b/src-tauri/client-cli/src/mfa_qr.rs new file mode 100644 index 00000000..a83b2903 --- /dev/null +++ b/src-tauri/client-cli/src/mfa_qr.rs @@ -0,0 +1,112 @@ +//! Mobile-approve MFA QR code payload construction and rendering. +//! +//! QR payload format (matches the desktop client): +//! Base64(JSON{token, challenge, instance_id}) +//! +//! The payload is never logged via tracing. + +use std::{io::IsTerminal, path::Path}; + +use base64::{prelude::BASE64_STANDARD, Engine as _}; +use image::Luma; +#[cfg(not(test))] +use qrcode::render::unicode::Dense1x2; +use qrcode::QrCode; + +use crate::state::CliError; +#[cfg(not(test))] +use std::io::stderr; + +/// Build the base64-encoded QR payload for mobile-approve MFA. +/// +/// The payload is a JSON object containing the MFA session token, the +/// biometric challenge, and the instance UUID. This matches the format +/// expected by the Defguard mobile app. +pub(crate) fn build_qr_payload(token: &str, challenge: &str, instance_id: &str) -> String { + let json = serde_json::json!({ + "token": token, + "challenge": challenge, + "instance_id": instance_id, + }); + let raw = serde_json::to_string(&json).expect("JSON serialization is infallible"); + BASE64_STANDARD.encode(raw.as_bytes()) +} + +/// Render the QR code for a payload string to available output(s). +/// +/// * When **stderr is a TTY**, prints a Unicode `Dense1x2` QR to stderr. +/// * When **`qr_file` is `Some`**, writes a PNG image to that path. +/// * If **neither** output is viable (non-TTY + no `qr_file`), returns +/// [`CliError::InvalidInput`] with guidance to use `--qr-file`. +/// +/// Both outputs are rendered independently -- when both are available the +/// user sees the terminal QR *and* gets a PNG file. +#[cfg(not(test))] +pub(crate) fn render_qr(payload: &str, qr_file: Option<&str>) -> Result<(), CliError> { + let is_tty = stderr().is_terminal(); + + if !is_tty && qr_file.is_none() { + return Err(CliError::InvalidInput( + "No QR display available (stderr is not a TTY). \ + Use --qr-file to save the QR as a PNG image." + .into(), + )); + } + + if is_tty { + let code = QrCode::new(payload.as_bytes()) + .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; + let rendered = code.render::().build(); + eprintln!("{rendered}"); + } + + if let Some(path) = qr_file { + let code = QrCode::new(payload.as_bytes()) + .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; + let image = code.render::>().build(); + image + .save(Path::new(path)) + .map_err(|e| CliError::Other(format!("Failed to save QR image: {e}")))?; + } + + Ok(()) +} + +#[cfg(test)] +pub(crate) fn render_qr(_payload: &str, qr_file: Option<&str>) -> Result<(), CliError> { + // Test mode: never render to the terminal. Write to --qr-file + // only so that integration tests can verify the file was produced. + if let Some(path) = qr_file { + let code = QrCode::new(_payload.as_bytes()) + .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; + let image = code.render::>().build(); + image + .save(Path::new(path)) + .map_err(|e| CliError::Other(format!("Failed to save QR image: {e}")))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_qr_payload_bytes() { + let payload = build_qr_payload("tok-abc", "chal-xyz", "uuid-001"); + // Decode and verify the JSON structure. + let decoded = BASE64_STANDARD.decode(&payload).expect("valid base64"); + let json: serde_json::Value = serde_json::from_slice(&decoded).expect("valid JSON"); + assert_eq!(json["token"], "tok-abc"); + assert_eq!(json["challenge"], "chal-xyz"); + assert_eq!(json["instance_id"], "uuid-001"); + } + + #[test] + fn test_build_qr_payload_deterministic() { + // Same inputs must produce identical payloads. + let a = build_qr_payload("tok", "chal", "inst"); + let b = build_qr_payload("tok", "chal", "inst"); + assert_eq!(a, b); + } +} From dfb5132a969467e441f8becec5897c025059f7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 16 Jun 2026 19:54:22 +0200 Subject: [PATCH 03/16] add initial mobile auth flow implementation --- src-tauri/client-cli/src/mfa.rs | 192 ++++++++++++++++++++++++++++- src-tauri/client-cli/src/mfa_qr.rs | 4 +- 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 06cf1c26..5e4ff5b6 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -6,6 +6,7 @@ use std::time::Duration; +use base64::{prelude::BASE64_STANDARD, Engine as _}; use defguard_client_proto::defguard::{ client_types::{ ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, @@ -23,20 +24,26 @@ use defguard_core::{ }, DbPool, }, - proxy::post_with_headers, + proxy::{construct_platform_header, post_with_headers}, + version::{CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, PKG_VERSION}, }; +use futures_util::StreamExt; +use http::Request; use reqwest::{StatusCode, Url}; use secrecy::{ExposeSecret, SecretString}; use serde::Deserialize; use tokio::{ - select, + net::TcpStream, + pin, select, signal::ctrl_c, time::{sleep, Instant}, }; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tracing::{debug, info, warn}; use crate::{ mfa_code::{obtain_code, CodeSource, MfaContext}, + mfa_qr, state::CliError, }; @@ -463,6 +470,187 @@ async fn poll_finish( } } +/// How long the CLI waits for the user to approve MFA on their mobile device. +#[cfg(not(test))] +const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(test)] +const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Run the mobile-approve MFA flow. +/// +/// Displays a QR code (terminal and/or `--qr-file` PNG), opens a WebSocket +/// to the proxy, and waits for the mobile app to approve the authentication. +/// The CLI performs no cryptography; the mobile device signs the challenge +/// and the proxy pushes the resulting preshared key back over the WebSocket. +/// +/// When `json_mode` is true, progress messages on stderr are suppressed so +/// that `--json` output consumers only see the final result/error. +pub(crate) async fn authorize_mobile_approve( + location: &Location, + instance: &Instance, + posture_data: Option, + qr_file: Option<&str>, + pool: &DbPool, + json_mode: bool, +) -> Result { + let wireguard_keys = WireguardKeys::find_by_instance_id(pool, instance.id) + .await + .map_err(|e| CliError::Other(e.to_string()))? + .ok_or_else(|| { + CliError::Other(format!( + "WireGuard keys not found for instance {}", + instance.name + )) + })?; + + let proxy_base = Url::parse(&instance.proxy_url) + .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; + check_proxy_scheme(&proxy_base, &instance.proxy_url); + + // Step 1: Start the MFA session. + let start_req = ClientMfaStartRequest { + location_id: location.network_id, + pubkey: wireguard_keys.pubkey.clone(), + method: MfaMethod::MobileApprove as i32, + posture_data, + }; + + let start_url = proxy_base + .join("api/v1/client-mfa/start") + .map_err(|e| CliError::Other(format!("Failed to build MFA start URL: {e}")))?; + + debug!("Starting mobile-approve MFA session at {start_url}"); + let response = post_with_headers(start_url, &start_req) + .await + .map_err(|e| CliError::Other(format!("Failed to reach proxy: {e}")))?; + + if !response.status().is_success() { + return Err(handle_mfa_error(response).await); + } + + let start_resp: ClientMfaStartResponse = response + .json() + .await + .map_err(|e| CliError::Other(format!("Invalid MFA start response: {e}")))?; + + // The challenge is always present for MobileApprove (core always generates + // a BiometricChallenge for this method). + let challenge = start_resp.challenge.ok_or_else(|| { + CliError::Other("Proxy did not return a challenge for mobile-approve MFA".into()) + })?; + + // Step 2: Build and render the QR code. + let payload = mfa_qr::build_qr_payload(&start_resp.token, &challenge, &instance.uuid); + if !json_mode { + mfa_qr::render_qr(&payload, qr_file)?; + eprintln!("Waiting for mobile approval... (Ctrl-C to cancel)"); + } + + // Step 3: Open a WebSocket and wait for the preshared key. + let ws_url = derive_ws_url(&proxy_base, &start_resp.token)?; + + let request = Request::builder() + .uri(&ws_url) + .header(CLIENT_VERSION_HEADER, PKG_VERSION) + .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) + .body(()) + .map_err(|e| CliError::Other(format!("Failed to build WebSocket request: {e}")))?; + + debug!("Connecting WebSocket to {ws_url}"); + let (ws_stream, _response) = connect_async(request) + .await + .map_err(|e| CliError::Other(format!("Failed to connect to proxy: {e}")))?; + + let psk = wait_for_mfa_success(ws_stream, MOBILE_APPROVE_TIMEOUT, json_mode).await?; + + info!("Mobile-approve MFA completed, preshared key obtained"); + Ok(SecretString::from(psk)) +} + +/// Derive the WebSocket URL from the proxy's base URL and the MFA token. +fn derive_ws_url(proxy_base: &Url, token: &str) -> Result { + let ws_scheme = match proxy_base.scheme() { + "https" => "wss", + "http" => "ws", + other => { + return Err(CliError::Other(format!( + "Invalid proxy URL scheme '{other}'; expected http or https" + ))); + } + }; + Ok(format!( + "{}://{}/api/v1/client-mfa/remote?token={token}", + ws_scheme, + proxy_base.authority(), + )) +} + +/// Wait on the WebSocket for a single `{"type":"mfa_success","preshared_key":"..."}` +/// text frame, or fail if the deadline expires or the user cancels. +async fn wait_for_mfa_success( + ws_stream: WebSocketStream>, + timeout: Duration, + json_mode: bool, +) -> Result { + let (_, mut read) = ws_stream.split(); + + loop { + let msg = select! { + _ = sleep(timeout) => { + if !json_mode { + eprintln!("Mobile approval timed out."); + } + return Err(CliError::MfaFailed( + "mobile approval timed out; re-run to get a fresh QR".into(), + )); + } + _ = ctrl_c() => { + if !json_mode { + eprintln!("MFA cancelled."); + } + return Err(CliError::Cancelled("MFA cancelled.".into())); + } + msg = read.next() => { + match msg { + Some(Ok(msg)) => msg, + Some(Err(e)) => { + return Err(CliError::Other(format!("WebSocket error: {e}"))); + } + None => { + // Server closed the connection without sending mfa_success. + if !json_mode { + eprintln!("Mobile approval timed out."); + } + return Err(CliError::MfaFailed( + "mobile approval timed out; re-run to get a fresh QR".into(), + )); + } + } + } + }; + + match msg { + Message::Text(text) => { + let parsed: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| CliError::Other(format!("Invalid WebSocket message: {e}")))?; + if let Some(key) = parsed["preshared_key"].as_str() { + return Ok(key.to_string()); + } + // Ignore unrecognised text frames. + } + Message::Close(_) => { + if !json_mode { + eprintln!("Mobile approval timed out."); + } + return Err(CliError::MfaFailed( + "mobile approval timed out; re-run to get a fresh QR".into(), + )); + } + _ => {} // Ignore ping, pong, binary. + } + } +} + #[cfg(test)] mod tests { use defguard_core::database::models::location::ServiceLocationMode; diff --git a/src-tauri/client-cli/src/mfa_qr.rs b/src-tauri/client-cli/src/mfa_qr.rs index a83b2903..7a3eabd6 100644 --- a/src-tauri/client-cli/src/mfa_qr.rs +++ b/src-tauri/client-cli/src/mfa_qr.rs @@ -5,6 +5,8 @@ //! //! The payload is never logged via tracing. +#[cfg(not(test))] +use std::io::stderr; use std::{io::IsTerminal, path::Path}; use base64::{prelude::BASE64_STANDARD, Engine as _}; @@ -14,8 +16,6 @@ use qrcode::render::unicode::Dense1x2; use qrcode::QrCode; use crate::state::CliError; -#[cfg(not(test))] -use std::io::stderr; /// Build the base64-encoded QR payload for mobile-approve MFA. /// From 09bb7306b8d68543920f91c478c65db15579311f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 16 Jun 2026 20:23:11 +0200 Subject: [PATCH 04/16] wire up mobile auth --- src-tauri/client-cli/src/commands/connect.rs | 23 ++++++++++++++-- src-tauri/client-cli/src/mfa.rs | 29 ++++++++------------ src-tauri/client-cli/src/mfa_qr.rs | 22 +++++++++++++-- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src-tauri/client-cli/src/commands/connect.rs b/src-tauri/client-cli/src/commands/connect.rs index 2dfdb2b1..1e95808a 100644 --- a/src-tauri/client-cli/src/commands/connect.rs +++ b/src-tauri/client-cli/src/commands/connect.rs @@ -1,4 +1,4 @@ -use std::io::{stdin, IsTerminal}; +use std::io::{stderr, stdin, IsTerminal}; use defguard_client_posture::{authorize_posture_session, get_posture_data}; use defguard_client_proto::defguard::client_types::MfaMethod; @@ -30,7 +30,7 @@ pub async fn handle( code: Option<&str>, code_command: Option<&str>, mfa_method: Option<&str>, - _qr_file: Option<&str>, + qr_file: Option<&str>, all_traffic: bool, predefined_traffic: bool, json: bool, @@ -107,6 +107,25 @@ pub async fn handle( let psk = if method == MfaMethod::Oidc { mfa::authorize_oidc(location, &instance, posture_data, &state.pool, json) .await? + } else if method == MfaMethod::MobileApprove { + // Fail-fast: if neither stderr is a TTY nor --qr-file is set, + // the user cannot scan the QR. Do not call /start. + if !stderr().is_terminal() && qr_file.is_none() { + return Err(CliError::InvalidInput( + "No QR display available (stderr is not a TTY). \ + Use --qr-file to save the QR as a PNG image." + .into(), + )); + } + mfa::authorize_mobile_approve( + location, + &instance, + posture_data, + qr_file, + &state.pool, + json, + ) + .await? } else { // Determine the MFA code source from CLI flags. let code_source = code diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 5e4ff5b6..ae10fdb5 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -1,12 +1,10 @@ //! Connect-time VPN MFA over `core::proxy` (HTTP). //! -//! Flow: `start` → `obtain_code` → `finish` → preshared_key. -//! Supports TOTP, email, and OIDC methods. Mobile-approve is -//! not yet supported by the CLI. +//! Flow: `start` → `obtain_code` / QR render → `finish` / WebSocket → preshared_key. +//! Supports TOTP, email, OIDC, and mobile-approve methods. use std::time::Duration; -use base64::{prelude::BASE64_STANDARD, Engine as _}; use defguard_client_proto::defguard::{ client_types::{ ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, @@ -24,17 +22,15 @@ use defguard_core::{ }, DbPool, }, - proxy::{construct_platform_header, post_with_headers}, - version::{CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, PKG_VERSION}, + proxy::post_with_headers, }; use futures_util::StreamExt; -use http::Request; use reqwest::{StatusCode, Url}; use secrecy::{ExposeSecret, SecretString}; use serde::Deserialize; use tokio::{ net::TcpStream, - pin, select, + select, signal::ctrl_c, time::{sleep, Instant}, }; @@ -115,11 +111,17 @@ pub async fn authorize( // accidentally invoke authorize() with OIDC, this catch-all is a // defense-in-depth barrier that emits a clear error. match method { - MfaMethod::Biometric | MfaMethod::MobileApprove => { + MfaMethod::Biometric => { return Err(CliError::MfaFailed(format!( "MFA method {method:?} is not yet supported by the CLI. Use the desktop client." ))); } + MfaMethod::MobileApprove => { + return Err(CliError::Other( + "Internal error: MobileApprove MFA must use authorize_mobile_approve, not authorize" + .into(), + )); + } MfaMethod::Oidc => { return Err(CliError::Other( "Internal error: OIDC MFA must use authorize_oidc, not authorize".into(), @@ -549,15 +551,8 @@ pub(crate) async fn authorize_mobile_approve( // Step 3: Open a WebSocket and wait for the preshared key. let ws_url = derive_ws_url(&proxy_base, &start_resp.token)?; - let request = Request::builder() - .uri(&ws_url) - .header(CLIENT_VERSION_HEADER, PKG_VERSION) - .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) - .body(()) - .map_err(|e| CliError::Other(format!("Failed to build WebSocket request: {e}")))?; - debug!("Connecting WebSocket to {ws_url}"); - let (ws_stream, _response) = connect_async(request) + let (ws_stream, _response) = connect_async(&ws_url) .await .map_err(|e| CliError::Other(format!("Failed to connect to proxy: {e}")))?; diff --git a/src-tauri/client-cli/src/mfa_qr.rs b/src-tauri/client-cli/src/mfa_qr.rs index 7a3eabd6..5344f09c 100644 --- a/src-tauri/client-cli/src/mfa_qr.rs +++ b/src-tauri/client-cli/src/mfa_qr.rs @@ -6,10 +6,12 @@ //! The payload is never logged via tracing. #[cfg(not(test))] -use std::io::stderr; -use std::{io::IsTerminal, path::Path}; +use std::io::{stderr, IsTerminal}; +use std::path::Path; use base64::{prelude::BASE64_STANDARD, Engine as _}; +#[cfg(not(test))] +use image::imageops::{resize, FilterType}; use image::Luma; #[cfg(not(test))] use qrcode::render::unicode::Dense1x2; @@ -17,6 +19,10 @@ use qrcode::QrCode; use crate::state::CliError; +#[cfg(not(test))] +// Target minimum size (in pixels) for QR PNG output. +const QR_PNG_MIN_SIZE: u32 = 300; + /// Build the base64-encoded QR payload for mobile-approve MFA. /// /// The payload is a JSON object containing the MFA session token, the @@ -64,7 +70,17 @@ pub(crate) fn render_qr(payload: &str, qr_file: Option<&str>) -> Result<(), CliE let code = QrCode::new(payload.as_bytes()) .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; let image = code.render::>().build(); - image + // Scale up so the QR is large enough to scan. + // Nearest-neighbour preserves sharp module edges. + let max_dim = image.width().max(image.height()); + let scale = (QR_PNG_MIN_SIZE + max_dim - 1) / max_dim.max(1); + let scaled = resize( + &image, + image.width() * scale, + image.height() * scale, + FilterType::Nearest, + ); + scaled .save(Path::new(path)) .map_err(|e| CliError::Other(format!("Failed to save QR image: {e}")))?; } From cb245821e8f3029c2f049df185c72eba9fb27171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 16 Jun 2026 20:23:22 +0200 Subject: [PATCH 05/16] refactor mfa flags validation --- src-tauri/client-cli/src/commands/connect.rs | 6 +- src-tauri/client-cli/src/mfa.rs | 118 ++++++++++++++----- 2 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src-tauri/client-cli/src/commands/connect.rs b/src-tauri/client-cli/src/commands/connect.rs index 1e95808a..d35fb9c7 100644 --- a/src-tauri/client-cli/src/commands/connect.rs +++ b/src-tauri/client-cli/src/commands/connect.rs @@ -82,8 +82,10 @@ pub async fn handle( ResolvedTarget::Location(location) => { if location.mfa_enabled() { // Resolve the effective MFA method. - // Also rejects --code / --code-command when the method is OIDC. - let method = mfa::resolve_method(location, mfa_method, code, code_command)?; + let method = mfa::resolve_method(location, mfa_method)?; + + // Reject flags that are incompatible with the resolved method. + mfa::validate_mfa_flags(method, &location.name, code, code_command, qr_file)?; let instance = Instance::find_by_id(&state.pool, location.instance_id) .await diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index ae10fdb5..5e44a9ca 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -43,21 +43,16 @@ use crate::{ state::CliError, }; -/// Resolve the effective MFA method for a location and validate CLI flags -/// that are incompatible with certain methods. +/// Resolve the effective MFA method for a location. /// /// When `method_override` is `Some`, parses it into [`MfaMethod`]; otherwise /// delegates to [`infer_method`] which respects the location's /// [`LocationMfaMode`]. /// -/// Rejects: -/// * `--mfa-method oidc` on Internal-mode locations -/// * `--code` / `--code-command` when the resolved method is OIDC +/// Rejects `--mfa-method oidc` on Internal-mode locations. pub(crate) fn resolve_method( location: &Location, method_override: Option<&str>, - code: Option<&str>, - code_command: Option<&str>, ) -> Result { let method = if let Some(raw) = method_override { let method = parse_method(raw)?; @@ -74,15 +69,36 @@ pub(crate) fn resolve_method( infer_method(location) }; - // OIDC MFA does not use codes; --code / --code-command are incompatible. - if method == MfaMethod::Oidc && (code.is_some() || code_command.is_some()) { + Ok(method) +} + +/// Validate CLI flags against the resolved MFA method. +/// +/// * `--code` / `--code-command` are incompatible with OIDC and mobile-approve +/// (neither method accepts textual codes). +/// * `--qr-file` is only valid for mobile-approve MFA. +pub(crate) fn validate_mfa_flags( + method: MfaMethod, + location_name: &str, + code: Option<&str>, + code_command: Option<&str>, + qr_file: Option<&str>, +) -> Result<(), CliError> { + if matches!(method, MfaMethod::Oidc | MfaMethod::MobileApprove) + && (code.is_some() || code_command.is_some()) + { return Err(CliError::InvalidInput(format!( - "location '{}' cannot use --code / --code-command with external (OIDC) MFA", - location.name + "location '{location_name}' cannot use --code / --code-command with {method:?} MFA", ))); } - Ok(method) + if method != MfaMethod::MobileApprove && qr_file.is_some() { + return Err(CliError::InvalidInput( + "--qr-file is only valid with mobile-approve MFA".into(), + )); + } + + Ok(()) } /// Run the VPN MFA handshake for a location. @@ -675,51 +691,99 @@ mod tests { #[test] fn test_oidc_location_resolves_to_oidc() { let l = location("office", LocationMfaMode::External); - let method = resolve_method(&l, None, None, None).unwrap(); + let method = resolve_method(&l, None).unwrap(); assert_eq!(method, MfaMethod::Oidc); } #[test] fn test_internal_location_resolves_to_totp() { let l = location("office", LocationMfaMode::Internal); - let method = resolve_method(&l, None, None, None).unwrap(); + let method = resolve_method(&l, None).unwrap(); assert_eq!(method, MfaMethod::Totp); } #[test] - fn test_code_with_oidc_rejected() { - let l = location("office", LocationMfaMode::External); - let err = resolve_method(&l, None, Some("123456"), None).unwrap_err(); + fn test_validate_flags_oidc_rejects_code() { + let err = + validate_mfa_flags(MfaMethod::Oidc, "office", Some("123456"), None, None).unwrap_err(); assert!(matches!(err, CliError::InvalidInput(_))); assert!(err.to_string().contains("--code")); } #[test] - fn test_code_command_with_oidc_rejected() { - let l = location("office", LocationMfaMode::External); - let err = resolve_method(&l, None, None, Some("pass otp")).unwrap_err(); + fn test_validate_flags_oidc_rejects_code_command() { + let err = validate_mfa_flags(MfaMethod::Oidc, "office", None, Some("pass otp"), None) + .unwrap_err(); assert!(matches!(err, CliError::InvalidInput(_))); - assert!(err.to_string().contains("--code-command")); + assert!(err.to_string().contains("--code")); } #[test] - fn test_code_with_totp_passes() { - let l = location("office", LocationMfaMode::Internal); - let method = resolve_method(&l, None, Some("123456"), None).unwrap(); - assert_eq!(method, MfaMethod::Totp); + fn test_validate_flags_mobile_approve_rejects_code() { + let err = validate_mfa_flags( + MfaMethod::MobileApprove, + "office", + Some("123456"), + None, + None, + ) + .unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("--code")); + } + + #[test] + fn test_validate_flags_mobile_approve_rejects_code_command() { + let err = validate_mfa_flags( + MfaMethod::MobileApprove, + "office", + None, + Some("pass otp"), + None, + ) + .unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("--code")); + } + + #[test] + fn test_validate_flags_qr_file_only_for_mobile_approve() { + // qr-file on TOTP + let err = + validate_mfa_flags(MfaMethod::Totp, "office", None, None, Some("qr.png")).unwrap_err(); + assert!(matches!(err, CliError::InvalidInput(_))); + assert!(err.to_string().contains("qr-file")); + } + + #[test] + fn test_validate_flags_qr_file_ok_for_mobile_approve() { + validate_mfa_flags( + MfaMethod::MobileApprove, + "office", + None, + None, + Some("qr.png"), + ) + .unwrap(); + } + + #[test] + fn test_validate_flags_pass_through_totp() { + // TOTP with --code should pass validation. + validate_mfa_flags(MfaMethod::Totp, "office", Some("123456"), None, None).unwrap(); } #[test] fn test_no_code_with_oidc_passes() { let l = location("office", LocationMfaMode::External); - let method = resolve_method(&l, None, None, None).unwrap(); + let method = resolve_method(&l, None).unwrap(); assert_eq!(method, MfaMethod::Oidc); } #[test] fn test_mfa_method_oidc_on_internal_rejected() { let l = location("office", LocationMfaMode::Internal); - let err = resolve_method(&l, Some("oidc"), None, None).unwrap_err(); + let err = resolve_method(&l, Some("oidc")).unwrap_err(); assert!(matches!(err, CliError::InvalidInput(_))); assert!(err.to_string().contains("oidc")); } From c78b48da748108028f064a4c73c6d364baa3471d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 16 Jun 2026 20:29:09 +0200 Subject: [PATCH 06/16] test mobile auth --- src-tauri/Cargo.lock | 1 + src-tauri/client-cli/Cargo.toml | 1 + src-tauri/client-cli/src/tests_proxy.rs | 144 +++++++++++++++++++++++- 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a0240d22..82c89a7d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1556,6 +1556,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "sha1", "sqlx", "tempfile", "thiserror 2.0.18", diff --git a/src-tauri/client-cli/Cargo.toml b/src-tauri/client-cli/Cargo.toml index ffd46ff9..e5f33f7c 100644 --- a/src-tauri/client-cli/Cargo.toml +++ b/src-tauri/client-cli/Cargo.toml @@ -41,6 +41,7 @@ futures-util.workspace = true custom-protocol = [] [dev-dependencies] +sha1 = "0.10" tempfile.workspace = true tokio-stream = "0.1" tonic.workspace = true diff --git a/src-tauri/client-cli/src/tests_proxy.rs b/src-tauri/client-cli/src/tests_proxy.rs index 2d11a0ae..f04ea5be 100644 --- a/src-tauri/client-cli/src/tests_proxy.rs +++ b/src-tauri/client-cli/src/tests_proxy.rs @@ -15,6 +15,7 @@ use std::{ time::Duration, }; +use base64::{prelude::BASE64_STANDARD, Engine as _}; use defguard_client_proto::defguard::client_types::MfaMethod; use defguard_core::database::{ models::{ @@ -25,6 +26,8 @@ use defguard_core::database::{ DbPool, }; use secrecy::ExposeSecret; +use serde_json::json; +use sha1::{Digest, Sha1}; use crate::{mfa, mfa_code::CodeSource, state::CliError}; @@ -39,7 +42,7 @@ struct MockResponse { body: String, } -/// A tiny HTTP server that responds to MFA start/finish requests. +/// A tiny HTTP server that responds to MFA start/finish/remote requests. struct MockProxy { addr: SocketAddr, shutdown: Arc, @@ -47,6 +50,88 @@ struct MockProxy { } impl MockProxy { + /// Mobile-approve MFA: /start returns a token+challenge, /remote upgrades to + /// WebSocket and sends the given preshared key. + fn with_mobile_approve(start_response: MockResponse, preshared_key: &str) -> Self { + let psk = preshared_key.to_string(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_thread = shutdown.clone(); + let handle = spawn(move || { + while !shutdown_thread.load(Ordering::Relaxed) { + let mut stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + sleep(WAIT_TIMEOUT); + continue; + } + Err(_) => break, + }; + stream.set_nonblocking(false).ok(); + stream.set_read_timeout(Some(READ_TIMEOUT)).ok(); + + // Read the full request head. + let mut data = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + data.extend_from_slice(&buf[..n]); + if data.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + Err(_) => break, + } + } + + let request = String::from_utf8_lossy(&data); + + if request.contains("/api/v1/client-mfa/remote") { + // WebSocket upgrade. + if let Some(key) = extract_header(&request, "Sec-WebSocket-Key") { + let accept = compute_ws_accept(&key); + let resp = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Accept: {accept}\r\n\ + \r\n" + ); + let _ = stream.write_all(resp.as_bytes()); + + // Send the mfa_success text frame. + let payload = json!({ + "type": "mfa_success", + "preshared_key": &psk, + }); + let payload_str = serde_json::to_string(&payload).unwrap(); + let frame = build_ws_text_frame(&payload_str); + let _ = stream.write_all(&frame); + } + } else if request.contains("/api/v1/client-mfa/start") { + let body = format!( + "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + start_response.status, + start_response.body.len(), + start_response.body, + ); + let _ = stream.write_all(body.as_bytes()); + } + // Unknown paths: no response, just close. + } + }); + MockProxy { + addr, + shutdown, + handle: Some(handle), + } + } + /// Single-shot finish: each finish request gets the same response. fn new(start_response: MockResponse, finish_response: MockResponse) -> Self { Self::with_counter(start_response, finish_response, None) @@ -162,6 +247,38 @@ impl Drop for MockProxy { } } +/// Extract a header value from an HTTP request string. +fn extract_header(request: &str, name: &str) -> Option { + let prefix = format!("{name}: "); + request + .lines() + .find(|l| l.to_lowercase().starts_with(&prefix.to_lowercase())) + .and_then(|l| l.split_once(": ").map(|(_, v)| v.trim().to_string())) +} + +/// Compute the Sec-WebSocket-Accept value per RFC 6455 §4.2.2. +fn compute_ws_accept(key: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(key.as_bytes()); + hasher.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + BASE64_STANDARD.encode(hasher.finalize()) +} + +/// Build a WebSocket text frame (unmasked, for server→client). +fn build_ws_text_frame(payload: &str) -> Vec { + let len = payload.len(); + let mut frame = vec![0x81]; // FIN + text opcode + if len < 126 { + frame.push(len as u8); + } else { + // Our test payloads are small; 16-bit length is sufficient. + frame.push(126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } + frame.extend_from_slice(payload.as_bytes()); + frame +} + fn mfa_enabled_location(name: &str, instance_id: Id) -> Location { Location { id: NoId, @@ -348,3 +465,28 @@ async fn test_oidc_mfa_times_out_when_never_completed(pool: DbPool) { assert!(matches!(err, CliError::MfaFailed(_))); assert!(err.to_string().contains("timed out")); } + +#[sqlx::test(migrations = "../migrations")] +async fn test_mobile_approve_success_returns_psk(pool: DbPool) { + let (mut instance, location) = seed_db(&pool).await; + + // /start must return a challenge — mobile-approve requires it. + let mock = MockProxy::with_mobile_approve( + MockResponse { + status: 200, + body: r#"{"token":"tok-mob","challenge":"chal-xyz"}"#.into(), + }, + "secret-mobile-psk", + ); + mock.wait_ready(); + instance.proxy_url = mock.url(); + + let psk = mfa::authorize_mobile_approve( + &location, &instance, None, // no posture data + None, // no qr-file (test render_qr is a no-op) + &pool, false, // not json mode + ) + .await + .unwrap(); + assert_eq!(psk.expose_secret(), "secret-mobile-psk"); +} From 619dadb3a50a0cc3e004bc99ea7426ec75135c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 07:09:50 +0200 Subject: [PATCH 07/16] wrap mobile-specific error --- src-tauri/client-cli/src/mfa.rs | 28 +++++++++++++++- src-tauri/client-cli/src/tests_proxy.rs | 44 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 5e44a9ca..373f8d1e 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -543,7 +543,7 @@ pub(crate) async fn authorize_mobile_approve( .map_err(|e| CliError::Other(format!("Failed to reach proxy: {e}")))?; if !response.status().is_success() { - return Err(handle_mfa_error(response).await); + return Err(handle_mobile_approve_start_error(response).await); } let start_resp: ClientMfaStartResponse = response @@ -578,6 +578,32 @@ pub(crate) async fn authorize_mobile_approve( Ok(SecretString::from(psk)) } +/// Handle a non-2xx response from /start during mobile-approve MFA. +/// +/// Rewraps the cryptic server error "selected MFA method is not available" +/// into actionable guidance telling the user to register a mobile authenticator. +async fn handle_mobile_approve_start_error(response: reqwest::Response) -> CliError { + let status = response.status(); + let error_body: Option = response.json().await.ok(); + let message = error_body + .and_then(|b| b.error) + .unwrap_or_else(|| format!("HTTP {status}")); + + if message.contains("selected MFA method is not available") { + return CliError::MfaFailed( + "No mobile authenticator is registered for your account. \ + Register one in the Defguard mobile app, then retry." + .into(), + ); + } + + if status.is_client_error() { + CliError::MfaFailed(format!("MFA error: {message}")) + } else { + CliError::Other(format!("Proxy error (HTTP {status}): {message}")) + } +} + /// Derive the WebSocket URL from the proxy's base URL and the MFA token. fn derive_ws_url(proxy_base: &Url, token: &str) -> Result { let ws_scheme = match proxy_base.scheme() { diff --git a/src-tauri/client-cli/src/tests_proxy.rs b/src-tauri/client-cli/src/tests_proxy.rs index f04ea5be..cd686f2b 100644 --- a/src-tauri/client-cli/src/tests_proxy.rs +++ b/src-tauri/client-cli/src/tests_proxy.rs @@ -490,3 +490,47 @@ async fn test_mobile_approve_success_returns_psk(pool: DbPool) { .unwrap(); assert_eq!(psk.expose_secret(), "secret-mobile-psk"); } + +#[sqlx::test(migrations = "../migrations")] +async fn test_mobile_approve_no_device_returns_guidance(pool: DbPool) { + let (mut instance, location) = seed_db(&pool).await; + + let mock = MockProxy::with_mobile_approve( + MockResponse { + status: 400, + body: r#"{"error":"selected MFA method is not available"}"#.into(), + }, + "unused", + ); + mock.wait_ready(); + instance.proxy_url = mock.url(); + + let err = mfa::authorize_mobile_approve(&location, &instance, None, None, &pool, false) + .await + .unwrap_err(); + + assert!(matches!(err, CliError::MfaFailed(_))); + assert!(err.to_string().contains("No mobile authenticator")); +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_mobile_approve_unrelated_error_passes_through(pool: DbPool) { + let (mut instance, location) = seed_db(&pool).await; + + let mock = MockProxy::with_mobile_approve( + MockResponse { + status: 500, + body: r#"{"error":"internal server error"}"#.into(), + }, + "unused", + ); + mock.wait_ready(); + instance.proxy_url = mock.url(); + + let err = mfa::authorize_mobile_approve(&location, &instance, None, None, &pool, false) + .await + .unwrap_err(); + + assert!(matches!(err, CliError::Other(_))); + assert!(err.to_string().contains("internal server error")); +} From 903f664a27c03e9d6bac7c534c8664da728225f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 07:14:48 +0200 Subject: [PATCH 08/16] handle timeout error --- src-tauri/client-cli/src/mfa.rs | 10 ++++- src-tauri/client-cli/src/tests_proxy.rs | 55 ++++++++++++++++++------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 373f8d1e..3efc24dc 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -650,8 +650,14 @@ async fn wait_for_mfa_success( msg = read.next() => { match msg { Some(Ok(msg)) => msg, - Some(Err(e)) => { - return Err(CliError::Other(format!("WebSocket error: {e}"))); + Some(Err(_)) => { + // Server closed or errored without sending mfa_success. + if !json_mode { + eprintln!("Mobile approval timed out."); + } + return Err(CliError::MfaFailed( + "mobile approval timed out; re-run to get a fresh QR".into(), + )); } None => { // Server closed the connection without sending mfa_success. diff --git a/src-tauri/client-cli/src/tests_proxy.rs b/src-tauri/client-cli/src/tests_proxy.rs index cd686f2b..52d8870b 100644 --- a/src-tauri/client-cli/src/tests_proxy.rs +++ b/src-tauri/client-cli/src/tests_proxy.rs @@ -51,9 +51,10 @@ struct MockProxy { impl MockProxy { /// Mobile-approve MFA: /start returns a token+challenge, /remote upgrades to - /// WebSocket and sends the given preshared key. - fn with_mobile_approve(start_response: MockResponse, preshared_key: &str) -> Self { - let psk = preshared_key.to_string(); + /// WebSocket and sends the given preshared key (if `Some`). When `None`, the + /// server closes the connection right after the handshake (used for timeout tests). + fn with_mobile_approve(start_response: MockResponse, preshared_key: Option<&str>) -> Self { + let psk = preshared_key.map(|s| s.to_string()); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap(); @@ -103,14 +104,17 @@ impl MockProxy { ); let _ = stream.write_all(resp.as_bytes()); - // Send the mfa_success text frame. - let payload = json!({ - "type": "mfa_success", - "preshared_key": &psk, - }); - let payload_str = serde_json::to_string(&payload).unwrap(); - let frame = build_ws_text_frame(&payload_str); - let _ = stream.write_all(&frame); + // Send the mfa_success text frame if we have a PSK. + if let Some(ref key) = psk { + let payload = json!({ + "type": "mfa_success", + "preshared_key": key, + }); + let payload_str = serde_json::to_string(&payload).unwrap(); + let frame = build_ws_text_frame(&payload_str); + let _ = stream.write_all(&frame); + } + // When psk is None, just close (no frame) to simulate timeout. } } else if request.contains("/api/v1/client-mfa/start") { let body = format!( @@ -476,7 +480,7 @@ async fn test_mobile_approve_success_returns_psk(pool: DbPool) { status: 200, body: r#"{"token":"tok-mob","challenge":"chal-xyz"}"#.into(), }, - "secret-mobile-psk", + Some("secret-mobile-psk"), ); mock.wait_ready(); instance.proxy_url = mock.url(); @@ -500,7 +504,7 @@ async fn test_mobile_approve_no_device_returns_guidance(pool: DbPool) { status: 400, body: r#"{"error":"selected MFA method is not available"}"#.into(), }, - "unused", + Some("unused"), ); mock.wait_ready(); instance.proxy_url = mock.url(); @@ -522,7 +526,7 @@ async fn test_mobile_approve_unrelated_error_passes_through(pool: DbPool) { status: 500, body: r#"{"error":"internal server error"}"#.into(), }, - "unused", + Some("unused"), ); mock.wait_ready(); instance.proxy_url = mock.url(); @@ -534,3 +538,26 @@ async fn test_mobile_approve_unrelated_error_passes_through(pool: DbPool) { assert!(matches!(err, CliError::Other(_))); assert!(err.to_string().contains("internal server error")); } + +#[sqlx::test(migrations = "../migrations")] +async fn test_mobile_approve_server_close_without_frame_times_out(pool: DbPool) { + let (mut instance, location) = seed_db(&pool).await; + + // psk=None: the mock accepts the WS upgrade but closes without sending a frame. + let mock = MockProxy::with_mobile_approve( + MockResponse { + status: 200, + body: r#"{"token":"tok-close","challenge":"chal-xyz"}"#.into(), + }, + None, + ); + mock.wait_ready(); + instance.proxy_url = mock.url(); + + let err = mfa::authorize_mobile_approve(&location, &instance, None, None, &pool, false) + .await + .unwrap_err(); + + assert!(matches!(err, CliError::MfaFailed(_))); + assert!(err.to_string().contains("timed out")); +} From b12f7d19e5d39ee95fa9845f38deae6bbf302bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 08:30:38 +0200 Subject: [PATCH 09/16] avoid logging token --- src-tauri/client-cli/src/mfa.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 3efc24dc..db5f4ea6 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -565,9 +565,8 @@ pub(crate) async fn authorize_mobile_approve( } // Step 3: Open a WebSocket and wait for the preshared key. + // Never log the token-bearing URL via tracing. let ws_url = derive_ws_url(&proxy_base, &start_resp.token)?; - - debug!("Connecting WebSocket to {ws_url}"); let (ws_stream, _response) = connect_async(&ws_url) .await .map_err(|e| CliError::Other(format!("Failed to connect to proxy: {e}")))?; From 9903529d12e49edd64dc3efd206cbb6a3e95fb4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 08:30:50 +0200 Subject: [PATCH 10/16] clarify errors --- src-tauri/client-cli/src/mfa.rs | 8 ++++---- src-tauri/client-cli/src/tests_proxy.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index db5f4ea6..d2825195 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -652,19 +652,19 @@ async fn wait_for_mfa_success( Some(Err(_)) => { // Server closed or errored without sending mfa_success. if !json_mode { - eprintln!("Mobile approval timed out."); + eprintln!("Mobile approval failed: connection closed by proxy."); } return Err(CliError::MfaFailed( - "mobile approval timed out; re-run to get a fresh QR".into(), + "mobile approval failed: connection closed by proxy".into(), )); } None => { // Server closed the connection without sending mfa_success. if !json_mode { - eprintln!("Mobile approval timed out."); + eprintln!("Mobile approval failed: connection closed by proxy."); } return Err(CliError::MfaFailed( - "mobile approval timed out; re-run to get a fresh QR".into(), + "mobile approval failed: connection closed by proxy".into(), )); } } diff --git a/src-tauri/client-cli/src/tests_proxy.rs b/src-tauri/client-cli/src/tests_proxy.rs index 52d8870b..071d5ada 100644 --- a/src-tauri/client-cli/src/tests_proxy.rs +++ b/src-tauri/client-cli/src/tests_proxy.rs @@ -559,5 +559,5 @@ async fn test_mobile_approve_server_close_without_frame_times_out(pool: DbPool) .unwrap_err(); assert!(matches!(err, CliError::MfaFailed(_))); - assert!(err.to_string().contains("timed out")); + assert!(err.to_string().contains("connection closed by proxy")); } From 29b1fec825a51c0087cbbe54cf34414824c1e50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 08:53:20 +0200 Subject: [PATCH 11/16] some cleanup --- src-tauri/client-cli/src/mfa.rs | 30 +++++++++++++++++++++++++----- src-tauri/client-cli/src/mfa_qr.rs | 24 +++++++++++++++++------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index d2825195..d3b7187d 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -559,8 +559,8 @@ pub(crate) async fn authorize_mobile_approve( // Step 2: Build and render the QR code. let payload = mfa_qr::build_qr_payload(&start_resp.token, &challenge, &instance.uuid); + mfa_qr::render_qr(&payload, qr_file, json_mode)?; if !json_mode { - mfa_qr::render_qr(&payload, qr_file)?; eprintln!("Waiting for mobile approval... (Ctrl-C to cancel)"); } @@ -623,16 +623,36 @@ fn derive_ws_url(proxy_base: &Url, token: &str) -> Result { /// Wait on the WebSocket for a single `{"type":"mfa_success","preshared_key":"..."}` /// text frame, or fail if the deadline expires or the user cancels. +/// +/// Uses an absolute deadline (not a per-frame-gap timeout) so that stray +/// ping/pong traffic does not silently extend the wait. async fn wait_for_mfa_success( ws_stream: WebSocketStream>, timeout: Duration, json_mode: bool, ) -> Result { - let (_, mut read) = ws_stream.split(); + let (write, mut read) = ws_stream.split(); + // Keep the write half alive so tungstenite can send automatic + // pong replies to server pings. Dropping it would prevent pongs. + let _write = write; + + let deadline = Instant::now() + timeout; loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + if remaining.is_zero() { + if !json_mode { + eprintln!("Mobile approval timed out."); + } + return Err(CliError::MfaFailed( + "mobile approval timed out; re-run to get a fresh QR".into(), + )); + } + let msg = select! { - _ = sleep(timeout) => { + _ = sleep(remaining) => { if !json_mode { eprintln!("Mobile approval timed out."); } @@ -682,10 +702,10 @@ async fn wait_for_mfa_success( } Message::Close(_) => { if !json_mode { - eprintln!("Mobile approval timed out."); + eprintln!("Mobile approval failed: connection closed by proxy."); } return Err(CliError::MfaFailed( - "mobile approval timed out; re-run to get a fresh QR".into(), + "mobile approval failed: connection closed by proxy".into(), )); } _ => {} // Ignore ping, pong, binary. diff --git a/src-tauri/client-cli/src/mfa_qr.rs b/src-tauri/client-cli/src/mfa_qr.rs index 5344f09c..005cdbc3 100644 --- a/src-tauri/client-cli/src/mfa_qr.rs +++ b/src-tauri/client-cli/src/mfa_qr.rs @@ -40,15 +40,21 @@ pub(crate) fn build_qr_payload(token: &str, challenge: &str, instance_id: &str) /// Render the QR code for a payload string to available output(s). /// -/// * When **stderr is a TTY**, prints a Unicode `Dense1x2` QR to stderr. -/// * When **`qr_file` is `Some`**, writes a PNG image to that path. +/// * When **stderr is a TTY** and `json_mode` is false, prints a Unicode +/// `Dense1x2` QR to stderr. +/// * When **`qr_file` is `Some`**, always writes a PNG image to that path +/// (regardless of `json_mode` - the file is machine-readable output). /// * If **neither** output is viable (non-TTY + no `qr_file`), returns /// [`CliError::InvalidInput`] with guidance to use `--qr-file`. /// -/// Both outputs are rendered independently -- when both are available the -/// user sees the terminal QR *and* gets a PNG file. +/// Terminal and file outputs are independent: when both are available +/// and `!json_mode`, the user sees the terminal QR *and* gets a PNG file. #[cfg(not(test))] -pub(crate) fn render_qr(payload: &str, qr_file: Option<&str>) -> Result<(), CliError> { +pub(crate) fn render_qr( + payload: &str, + qr_file: Option<&str>, + json_mode: bool, +) -> Result<(), CliError> { let is_tty = stderr().is_terminal(); if !is_tty && qr_file.is_none() { @@ -59,7 +65,7 @@ pub(crate) fn render_qr(payload: &str, qr_file: Option<&str>) -> Result<(), CliE )); } - if is_tty { + if is_tty && !json_mode { let code = QrCode::new(payload.as_bytes()) .map_err(|e| CliError::Other(format!("Failed to generate QR code: {e}")))?; let rendered = code.render::().build(); @@ -89,7 +95,11 @@ pub(crate) fn render_qr(payload: &str, qr_file: Option<&str>) -> Result<(), CliE } #[cfg(test)] -pub(crate) fn render_qr(_payload: &str, qr_file: Option<&str>) -> Result<(), CliError> { +pub(crate) fn render_qr( + _payload: &str, + qr_file: Option<&str>, + _json_mode: bool, +) -> Result<(), CliError> { // Test mode: never render to the terminal. Write to --qr-file // only so that integration tests can verify the file was produced. if let Some(path) = qr_file { From 25571b13a5697342e2690afc2441108ef84675ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 09:25:55 +0200 Subject: [PATCH 12/16] cleaning up some more stuff --- src-tauri/client-cli/src/mfa.rs | 48 +++++++++++++++++------------- src-tauri/client-cli/src/mfa_qr.rs | 10 ++----- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index d3b7187d..c40eb11a 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -1,6 +1,5 @@ //! Connect-time VPN MFA over `core::proxy` (HTTP). //! -//! Flow: `start` → `obtain_code` / QR render → `finish` / WebSocket → preshared_key. //! Supports TOTP, email, OIDC, and mobile-approve methods. use std::time::Duration; @@ -129,7 +128,7 @@ pub async fn authorize( match method { MfaMethod::Biometric => { return Err(CliError::MfaFailed(format!( - "MFA method {method:?} is not yet supported by the CLI. Use the desktop client." + "MFA method {method:?} is not supported by the CLI. Use the mobile client." ))); } MfaMethod::MobileApprove => { @@ -156,13 +155,13 @@ pub async fn authorize( )) })?; - // Parse the proxy base URL once and reuse it for both MFA requests. - let proxy_base = Url::parse(&instance.proxy_url) + // Parse the proxy URL once and reuse it for both MFA requests. + let proxy_url = Url::parse(&instance.proxy_url) .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; // The one-time MFA code and the returned preshared key are sensitive; warn (but do // not block) if the proxy is not using HTTPS, since they would travel in cleartext. - check_proxy_scheme(&proxy_base, &instance.proxy_url); + check_proxy_scheme(&proxy_url); // Step 1: Start the MFA session. let start_req = ClientMfaStartRequest { @@ -172,7 +171,7 @@ pub async fn authorize( posture_data, }; - let start_url = proxy_base + let start_url = proxy_url .join("api/v1/client-mfa/start") .map_err(|e| CliError::Other(format!("Failed to build MFA start URL: {e}")))?; @@ -207,7 +206,7 @@ pub async fn authorize( auth_pub_key: None, }; - let finish_url = proxy_base + let finish_url = proxy_url .join("api/v1/client-mfa/finish") .map_err(|e| CliError::Other(format!("Failed to build MFA finish URL: {e}")))?; @@ -295,9 +294,12 @@ fn infer_method(location: &Location) -> MfaMethod { /// /// The one-time MFA code and the returned preshared key are sensitive and would /// travel in cleartext over plain HTTP. -fn check_proxy_scheme(proxy_base: &Url, proxy_url: &str) { +fn check_proxy_scheme(proxy_base: &Url) { if proxy_base.scheme() != "https" { - warn!("Proxy URL '{proxy_url}' is not HTTPS; secrets will be sent in cleartext."); + warn!( + "Proxy URL '{}' is not HTTPS; secrets will be sent in cleartext.", + proxy_base.as_str() + ); } } @@ -363,7 +365,7 @@ pub(crate) async fn authorize_oidc( let proxy_base = Url::parse(&instance.proxy_url) .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; - check_proxy_scheme(&proxy_base, &instance.proxy_url); + check_proxy_scheme(&proxy_base); // Step 1: Start the OIDC MFA session. let start_req = ClientMfaStartRequest { @@ -523,7 +525,7 @@ pub(crate) async fn authorize_mobile_approve( let proxy_base = Url::parse(&instance.proxy_url) .map_err(|e| CliError::Other(format!("Invalid proxy URL: {e}")))?; - check_proxy_scheme(&proxy_base, &instance.proxy_url); + check_proxy_scheme(&proxy_base); // Step 1: Start the MFA session. let start_req = ClientMfaStartRequest { @@ -565,7 +567,6 @@ pub(crate) async fn authorize_mobile_approve( } // Step 3: Open a WebSocket and wait for the preshared key. - // Never log the token-bearing URL via tracing. let ws_url = derive_ws_url(&proxy_base, &start_resp.token)?; let (ws_stream, _response) = connect_async(&ws_url) .await @@ -604,7 +605,13 @@ async fn handle_mobile_approve_start_error(response: reqwest::Response) -> CliEr } /// Derive the WebSocket URL from the proxy's base URL and the MFA token. +/// +/// Uses [`Url::join`] so that any path prefix is preserved. fn derive_ws_url(proxy_base: &Url, token: &str) -> Result { + let mut ws_url = proxy_base + .join("api/v1/client-mfa/remote") + .map_err(|e| CliError::Other(format!("Failed to build WebSocket URL: {e}")))?; + let ws_scheme = match proxy_base.scheme() { "https" => "wss", "http" => "ws", @@ -614,11 +621,13 @@ fn derive_ws_url(proxy_base: &Url, token: &str) -> Result { ))); } }; - Ok(format!( - "{}://{}/api/v1/client-mfa/remote?token={token}", - ws_scheme, - proxy_base.authority(), - )) + + ws_url + .set_scheme(ws_scheme) + .map_err(|()| CliError::Other("Failed to set WebSocket URL scheme".into()))?; + ws_url.query_pairs_mut().append_pair("token", token); + + Ok(ws_url.to_string()) } /// Wait on the WebSocket for a single `{"type":"mfa_success","preshared_key":"..."}` @@ -631,10 +640,7 @@ async fn wait_for_mfa_success( timeout: Duration, json_mode: bool, ) -> Result { - let (write, mut read) = ws_stream.split(); - // Keep the write half alive so tungstenite can send automatic - // pong replies to server pings. Dropping it would prevent pongs. - let _write = write; + let (_write, mut read) = ws_stream.split(); let deadline = Instant::now() + timeout; diff --git a/src-tauri/client-cli/src/mfa_qr.rs b/src-tauri/client-cli/src/mfa_qr.rs index 005cdbc3..56921852 100644 --- a/src-tauri/client-cli/src/mfa_qr.rs +++ b/src-tauri/client-cli/src/mfa_qr.rs @@ -1,9 +1,4 @@ //! Mobile-approve MFA QR code payload construction and rendering. -//! -//! QR payload format (matches the desktop client): -//! Base64(JSON{token, challenge, instance_id}) -//! -//! The payload is never logged via tracing. #[cfg(not(test))] use std::io::{stderr, IsTerminal}; @@ -25,9 +20,8 @@ const QR_PNG_MIN_SIZE: u32 = 300; /// Build the base64-encoded QR payload for mobile-approve MFA. /// -/// The payload is a JSON object containing the MFA session token, the -/// biometric challenge, and the instance UUID. This matches the format -/// expected by the Defguard mobile app. +/// QR payload format: +/// Base64(JSON{token, challenge, instance_id}) pub(crate) fn build_qr_payload(token: &str, challenge: &str, instance_id: &str) -> String { let json = serde_json::json!({ "token": token, From 7a5c81eb9369c3a895d0e340ac8b9825a7ea47d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 11:52:07 +0200 Subject: [PATCH 13/16] increase timeout --- src-tauri/client-cli/src/mfa.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index c40eb11a..718e11a5 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -492,7 +492,7 @@ async fn poll_finish( /// How long the CLI waits for the user to approve MFA on their mobile device. #[cfg(not(test))] -const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_secs(60); +const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_secs(120); #[cfg(test)] const MOBILE_APPROVE_TIMEOUT: Duration = Duration::from_secs(5); From 8d0ad3e26a354c7f984db1c72a49a9a7d3c25514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 11:52:54 +0200 Subject: [PATCH 14/16] verify type --- src-tauri/client-cli/src/mfa.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 718e11a5..d279e634 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -699,12 +699,14 @@ async fn wait_for_mfa_success( match msg { Message::Text(text) => { - let parsed: serde_json::Value = serde_json::from_str(&text) - .map_err(|e| CliError::Other(format!("Invalid WebSocket message: {e}")))?; - if let Some(key) = parsed["preshared_key"].as_str() { - return Ok(key.to_string()); + if let Ok(parsed) = serde_json::from_str::(&text) { + if parsed.get("type").and_then(|v| v.as_str()) == Some("mfa_success") { + if let Some(key) = parsed["preshared_key"].as_str() { + return Ok(key.to_string()); + } + } } - // Ignore unrecognised text frames. + // Ignore unrecognised text frames (non-JSON, wrong type, or missing preshared_key). } Message::Close(_) => { if !json_mode { From d569d838a6282bd8bd4f899c901aa3d0379ad810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 12:03:10 +0200 Subject: [PATCH 15/16] add clearer messages pointing to the desktop client --- src-tauri/client-cli/src/commands/instance.rs | 7 +++++-- src-tauri/client-cli/src/commands/location.rs | 7 +++++-- src-tauri/client-cli/src/commands/tunnel.rs | 7 +++++-- src-tauri/client-cli/src/state.rs | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src-tauri/client-cli/src/commands/instance.rs b/src-tauri/client-cli/src/commands/instance.rs index b816badf..7f0af465 100644 --- a/src-tauri/client-cli/src/commands/instance.rs +++ b/src-tauri/client-cli/src/commands/instance.rs @@ -29,7 +29,7 @@ pub struct InstanceListResult { impl CommandOutput for InstanceListResult { fn human(&self) -> String { if self.instances.is_empty() { - "No instances configured.".to_string() + "No instances configured. Use the desktop app to enroll first.".to_string() } else { format_instance_list_table(&self.instances) } @@ -149,7 +149,10 @@ mod tests { let result = InstanceListResult { instances: Vec::new(), }; - assert_eq!(result.human(), "No instances configured."); + assert_eq!( + result.human(), + "No instances configured. Use the desktop app to enroll first." + ); } #[test] diff --git a/src-tauri/client-cli/src/commands/location.rs b/src-tauri/client-cli/src/commands/location.rs index 63e922af..174b212a 100644 --- a/src-tauri/client-cli/src/commands/location.rs +++ b/src-tauri/client-cli/src/commands/location.rs @@ -139,7 +139,7 @@ pub struct LocationListResult { impl CommandOutput for LocationListResult { fn human(&self) -> String { if self.locations.is_empty() { - "No locations configured.".to_string() + "No locations configured. Use the desktop app to enroll an instance first.".to_string() } else { format_location_list_table(&self.locations, &self.instance_names) } @@ -328,7 +328,10 @@ mod tests { locations: Vec::new(), instance_names: HashMap::new(), }; - assert_eq!(result.human(), "No locations configured."); + assert_eq!( + result.human(), + "No locations configured. Use the desktop app to enroll an instance first." + ); } #[test] diff --git a/src-tauri/client-cli/src/commands/tunnel.rs b/src-tauri/client-cli/src/commands/tunnel.rs index 5e0ee204..71eda162 100644 --- a/src-tauri/client-cli/src/commands/tunnel.rs +++ b/src-tauri/client-cli/src/commands/tunnel.rs @@ -41,7 +41,7 @@ pub struct TunnelListResult { impl CommandOutput for TunnelListResult { fn human(&self) -> String { if self.tunnels.is_empty() { - "No tunnels configured.".to_string() + "No tunnels configured. Import tunnels via the desktop app.".to_string() } else { format_tunnel_list_table(&self.tunnels) } @@ -199,7 +199,10 @@ mod tests { let result = TunnelListResult { tunnels: Vec::new(), }; - assert_eq!(result.human(), "No tunnels configured."); + assert_eq!( + result.human(), + "No tunnels configured. Import tunnels via the desktop app." + ); } #[test] diff --git a/src-tauri/client-cli/src/state.rs b/src-tauri/client-cli/src/state.rs index 9e10c448..29b14fbb 100644 --- a/src-tauri/client-cli/src/state.rs +++ b/src-tauri/client-cli/src/state.rs @@ -35,7 +35,7 @@ pub enum CliError { #[error("MFA input required but no TTY: {0}")] MfaInputRequired(String), - #[error("not enrolled: {0}")] + #[error("enrollment required: {0}")] NotEnrolled(String), #[error("invalid input: {0}")] From cd6c43bed0dc86495595a367c4e09cf35f84feb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 17 Jun 2026 12:09:16 +0200 Subject: [PATCH 16/16] skip some tests on windows & mac --- src-tauri/client-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 50a92b53..ba6f5fa4 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -14,7 +14,7 @@ mod mfa_qr; mod output; mod resolve; mod state; -#[cfg(test)] +#[cfg(all(test, target_os = "linux"))] mod tests_daemon; #[cfg(test)] mod tests_proxy;