summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEmīls <emils@mullvad.net>2021-12-10 10:00:20 +0000
committerEmīls <emils@mullvad.net>2021-12-10 10:00:20 +0000
commitd6fe6573211bfc39d30ecaca67e308aa35399985 (patch)
treed493a08c2734e7ec9aac24e361ee535759014323
parent1e1c5c12b31b6450382a2036e9ab7a4e33d08ebb (diff)
parent8fc67488c8d5dfafc0033515bce3f90ccb42e4ad (diff)
downloadmullvadvpn-d6fe6573211bfc39d30ecaca67e308aa35399985.tar.xz
mullvadvpn-d6fe6573211bfc39d30ecaca67e308aa35399985.zip
Merge branch 'macos-add-dns-server'
-rw-r--r--CHANGELOG.md5
-rw-r--r--Cargo.lock314
-rwxr-xr-xdist-assets/pkg-scripts/preinstall13
-rwxr-xr-xdist-assets/uninstall_macos.sh3
-rw-r--r--docs/allow-macos-network-check.md95
-rw-r--r--docs/security.md14
-rw-r--r--gui/locales/messages.pot8
-rw-r--r--gui/src/main/daemon-rpc.ts4
-rw-r--r--gui/src/shared/daemon-rpc-types.ts2
-rw-r--r--gui/src/shared/notifications/error.ts10
-rw-r--r--mullvad-cli/src/cmds/mod.rs7
-rw-r--r--mullvad-cli/src/cmds/network_check.rs73
-rw-r--r--mullvad-daemon/Cargo.toml1
-rw-r--r--mullvad-daemon/src/exclusion_gid.rs53
-rw-r--r--mullvad-daemon/src/lib.rs110
-rw-r--r--mullvad-daemon/src/logging.rs3
-rw-r--r--mullvad-daemon/src/management_interface.rs32
-rw-r--r--mullvad-daemon/src/settings.rs12
-rw-r--r--mullvad-management-interface/proto/management_interface.proto4
-rw-r--r--mullvad-management-interface/src/types.rs14
-rw-r--r--mullvad-setup/src/main.rs2
-rw-r--r--mullvad-types/src/settings/mod.rs5
-rw-r--r--talpid-core/Cargo.toml10
-rw-r--r--talpid-core/src/dns/macos.rs182
-rw-r--r--talpid-core/src/dns/mod.rs18
-rw-r--r--talpid-core/src/firewall/macos.rs58
-rw-r--r--talpid-core/src/firewall/mod.rs13
-rw-r--r--talpid-core/src/lib.rs4
-rw-r--r--talpid-core/src/resolver/mod.rs822
-rw-r--r--talpid-core/src/tunnel_state_machine/connected_state.rs12
-rw-r--r--talpid-core/src/tunnel_state_machine/connecting_state.rs65
-rw-r--r--talpid-core/src/tunnel_state_machine/disconnected_state.rs184
-rw-r--r--talpid-core/src/tunnel_state_machine/disconnecting_state.rs41
-rw-r--r--talpid-core/src/tunnel_state_machine/error_state.rs231
-rw-r--r--talpid-core/src/tunnel_state_machine/mod.rs81
-rw-r--r--talpid-types/src/tunnel.rs20
36 files changed, 2448 insertions, 77 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a410df62f..9e68fb7cdc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,11 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
+### Added
+#### macOS
+- Add an opt-in feature to leak macOS network check traffic in blocked states to resolve issues with
+ the app blocking internet connectivity after sleep or when connecting to new wireless networks.
+
### Changed
- Keep unspecified constraints unchanged in the CLI when providing specific tunnel constraints
instead of setting them to default values.
diff --git a/Cargo.lock b/Cargo.lock
index 75319a4372..f61e4a57b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -193,7 +193,7 @@ dependencies = [
"num-integer",
"num-traits",
"serde",
- "time",
+ "time 0.1.43",
"winapi 0.3.9",
]
@@ -342,6 +342,12 @@ dependencies = [
]
[[package]]
+name = "data-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57"
+
+[[package]]
name = "dbus"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -450,6 +456,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
[[package]]
+name = "endian-type"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
+
+[[package]]
+name = "enum-as-inner"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "env_logger"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -473,6 +497,19 @@ dependencies = [
]
[[package]]
+name = "env_logger"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
+dependencies = [
+ "atty",
+ "humantime",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
name = "err-context"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -558,6 +595,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
+name = "form_urlencoded"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
+dependencies = [
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
name = "fsevent"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -774,6 +821,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
+name = "hostname"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
+dependencies = [
+ "libc",
+ "match_cfg",
+ "winapi 0.3.9",
+]
+
+[[package]]
name = "htmlize"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -840,7 +898,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
- "socket2",
+ "socket2 0.4.2",
"tokio",
"tower-service",
"tracing",
@@ -883,6 +941,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
+name = "idna"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
+dependencies = [
+ "matches",
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
name = "indexmap"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -946,6 +1015,24 @@ dependencies = [
]
[[package]]
+name = "ipconfig"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7e2f18aece9709094573a9f24f483c4f65caa4298e2f7ae1b71cc65d853fad7"
+dependencies = [
+ "socket2 0.3.19",
+ "widestring 0.4.3",
+ "winapi 0.3.9",
+ "winreg 0.6.2",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9"
+
+[[package]]
name = "ipnetwork"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1060,6 +1147,12 @@ dependencies = [
]
[[package]]
+name = "linked-hash-map"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
+
+[[package]]
name = "lock_api"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1087,6 +1180,27 @@ dependencies = [
]
[[package]]
+name = "lru-cache"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "match_cfg"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
+
+[[package]]
+name = "matches"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
+
+[[package]]
name = "memchr"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1230,6 +1344,7 @@ dependencies = [
"ctrlc",
"dirs-next",
"duct",
+ "either",
"err-derive",
"fern",
"futures",
@@ -1520,6 +1635,15 @@ dependencies = [
]
[[package]]
+name = "nibble_vec"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
name = "nix"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1949,6 +2073,16 @@ dependencies = [
]
[[package]]
+name = "radix_trie"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
+dependencies = [
+ "endian-type",
+ "nibble_vec",
+]
+
+[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2080,6 +2214,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00"
dependencies = [
+ "hostname",
"quick-error",
]
@@ -2345,6 +2480,26 @@ checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309"
[[package]]
name = "socket2"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e"
+dependencies = [
+ "cfg-if 1.0.0",
+ "libc",
+ "winapi 0.3.9",
+]
+
+[[package]]
+name = "socket2"
+version = "0.4.0"
+source = "git+https://github.com/rust-lang/socket2?rev=f7023b4c810eb7b0fedf23a1752461c41765c797#f7023b4c810eb7b0fedf23a1752461c41765c797"
+dependencies = [
+ "libc",
+ "winapi 0.3.9",
+]
+
+[[package]]
+name = "socket2"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dc90fe6c7be1a323296982db1836d1ea9e47b6839496dde9a541bc496df3516"
@@ -2465,6 +2620,7 @@ dependencies = [
"cfg-if 1.0.0",
"chrono",
"duct",
+ "either",
"err-derive",
"futures",
"hex",
@@ -2496,7 +2652,7 @@ dependencies = [
"resolv-conf",
"rtnetlink",
"shell-escape",
- "socket2",
+ "socket2 0.4.0",
"subslice",
"system-configuration",
"talpid-dbus",
@@ -2508,13 +2664,14 @@ dependencies = [
"tonic",
"tonic-build",
"triggered",
+ "trust-dns-server",
"tun",
"udp-over-tcp",
"uuid",
"which",
"widestring 0.5.1",
"winapi 0.3.9",
- "winreg",
+ "winreg 0.7.0",
"zeroize",
]
@@ -2634,6 +2791,30 @@ dependencies = [
]
[[package]]
+name = "time"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41effe7cfa8af36f439fac33861b66b049edc6f9a32331e2312660529c1c24ad"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
+
+[[package]]
name = "tokio"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2855,6 +3036,94 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce148eae0d1a376c1b94ae651fc3261d9cb8294788b962b7382066376503a2d1"
[[package]]
+name = "trust-dns-client"
+version = "0.21.0-alpha.4"
+source = "git+https://github.com/mullvad/trust-dns?rev=c782de0645335d1893a854337b965dd07790c068#c782de0645335d1893a854337b965dd07790c068"
+dependencies = [
+ "cfg-if 1.0.0",
+ "data-encoding",
+ "futures-channel",
+ "futures-util",
+ "lazy_static",
+ "log",
+ "radix_trie",
+ "rand 0.8.4",
+ "thiserror",
+ "time 0.3.5",
+ "tokio",
+ "trust-dns-proto",
+]
+
+[[package]]
+name = "trust-dns-proto"
+version = "0.21.0-alpha.4"
+source = "git+https://github.com/mullvad/trust-dns?rev=c782de0645335d1893a854337b965dd07790c068#c782de0645335d1893a854337b965dd07790c068"
+dependencies = [
+ "async-trait",
+ "cfg-if 1.0.0",
+ "data-encoding",
+ "enum-as-inner",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "idna",
+ "ipnet",
+ "lazy_static",
+ "log",
+ "rand 0.8.4",
+ "serde",
+ "smallvec",
+ "thiserror",
+ "tinyvec",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "trust-dns-resolver"
+version = "0.21.0-alpha.4"
+source = "git+https://github.com/mullvad/trust-dns?rev=c782de0645335d1893a854337b965dd07790c068#c782de0645335d1893a854337b965dd07790c068"
+dependencies = [
+ "async-trait",
+ "cfg-if 1.0.0",
+ "futures-util",
+ "ipconfig",
+ "lazy_static",
+ "log",
+ "lru-cache",
+ "parking_lot",
+ "resolv-conf",
+ "serde",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "trust-dns-proto",
+]
+
+[[package]]
+name = "trust-dns-server"
+version = "0.21.0-alpha.4"
+source = "git+https://github.com/mullvad/trust-dns?rev=c782de0645335d1893a854337b965dd07790c068#c782de0645335d1893a854337b965dd07790c068"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "cfg-if 1.0.0",
+ "enum-as-inner",
+ "env_logger 0.9.0",
+ "futures-executor",
+ "futures-util",
+ "log",
+ "serde",
+ "thiserror",
+ "time 0.3.5",
+ "tokio",
+ "toml",
+ "trust-dns-client",
+ "trust-dns-proto",
+ "trust-dns-resolver",
+]
+
+[[package]]
name = "try-lock"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2899,6 +3168,21 @@ dependencies = [
]
[[package]]
+name = "unicode-bidi"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
name = "unicode-segmentation"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2932,6 +3216,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
+name = "url"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "matches",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
name = "urlencoding"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3151,6 +3448,15 @@ dependencies = [
[[package]]
name = "winreg"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2986deb581c4fe11b621998a5e53361efe6b48a151178d0cd9eeffa4dc6acc9"
+dependencies = [
+ "winapi 0.3.9",
+]
+
+[[package]]
+name = "winreg"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69"
diff --git a/dist-assets/pkg-scripts/preinstall b/dist-assets/pkg-scripts/preinstall
index ef6d953970..c8561c0730 100755
--- a/dist-assets/pkg-scripts/preinstall
+++ b/dist-assets/pkg-scripts/preinstall
@@ -30,3 +30,16 @@ fi
# There is a risk that they're incompatible with the format this version wants
rm "$NEW_CACHE_DIR/relays.json" || true
rm "$NEW_CACHE_DIR/api-ip-address.txt" || true
+
+# Create a group for mullvad-exclusion
+MULLVAD_EXCLUSION_GROUP="mullvad-exclusion"
+if ! dscl . -list /Groups | grep $MULLVAD_EXCLUSION_GROUP; then
+ dscl . -create /Groups/$MULLVAD_EXCLUSION_GROUP \
+ || echo "FAILED TO CREATE $MULLVAD_EXCLUSION_GROUP GROUP"
+fi
+if ! dscl . -read /Groups/$MULLVAD_EXCLUSION_GROUP | grep PrimaryGroupID; then
+ MULLVAD_EXCLUSION_GID=$(( RANDOM ))
+ dscl . -append /Groups/$MULLVAD_EXCLUSION_GROUP PrimaryGroupID $MULLVAD_EXCLUSION_GID \
+ && echo "Created mullvad-exclusion group with gid $MULLVAD_EXCLUSION_GID" \
+ || echo "FAILED TO CREATE 'mullvad-exclusion' group"
+fi
diff --git a/dist-assets/uninstall_macos.sh b/dist-assets/uninstall_macos.sh
index 83316da3ba..7833ba528f 100755
--- a/dist-assets/uninstall_macos.sh
+++ b/dist-assets/uninstall_macos.sh
@@ -18,6 +18,9 @@ DAEMON_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.daemon.plist"
sudo launchctl unload -w "$DAEMON_PLIST_PATH"
sudo rm -f "$DAEMON_PLIST_PATH"
+sudo dscl . -delete /groups/mullvad-exclusion || echo "Failed to remove 'mullvad-exclusion' group"
+
+
echo "Resetting firewall"
sudo /Applications/Mullvad\ VPN.app/Contents/Resources/mullvad-setup reset-firewall
sudo /Applications/Mullvad\ VPN.app/Contents/Resources/mullvad-setup remove-wireguard-key
diff --git a/docs/allow-macos-network-check.md b/docs/allow-macos-network-check.md
new file mode 100644
index 0000000000..82a08afde9
--- /dev/null
+++ b/docs/allow-macos-network-check.md
@@ -0,0 +1,95 @@
+# Issues with macOS getting stuck in the offline state for too long
+
+When macOS is coming back from sleep or connecting to a new WiFi network, it may try to send various
+requests over the internet before it publishes a default route to the routing table. Since our
+daemon relies on the routing table to obtain a default route to route traffic to relays and bridges,
+and since macOS's network reachability seemingly does too, the daemon won't be able to connect to a
+relay and thus stay in the blocked state for a prolonged time. The default route is only published
+when macOS finishes or times out it's captive portal check. The captive portal check involves
+looking up `captive.apple.com` and issuing an HTTP request to the resolved address, and by default,
+if the app is blocking traffic, none of these network operations can take place, so the timeout is
+always incurred, which forces the app into the offline error state for a prolonged time.
+
+To not have to wait for macOS to time out it's captive portal check, the app should allow the
+captive portal check even when it's in a blocking state, whilst still blocking all arbitrary DNS
+traffic. This necessitates filtering DNS traffic at the application layer rather than the network
+layer, so only the request for `captive.apple.com` is leaked, and before the response is returned,
+the firewall rules are updated to allow the resolved addresses from the response to be reachable.
+
+# Leaking macOS network-check traffic
+
+To allow macOS's network-check to function, _some_ DNS queries need to leaked during a blocked
+state. This can be done via using a resolver that is selectively reacts to some DNS queries and is
+able to reach upstream resolvers when the app is in a blocking state. For now, this is achieved by
+excluding all traffic from a Mullvad specific group, and having the resolver run as part of the
+daemon, which asserts the groups ID on startup. The firewall rules that exclude the resolver traffic
+and the resolved IP addresses should only be in effect if the app has been configured to allow macOS
+network check. When receiving upstream responses, the DNS server in question should first have the
+firewall be reconfigured such that the resolved IP addresses are reachable.
+
+## Filtering resolver's dependencies
+
+To enable the custom resolver, certain conditions in the rest of the daemon need to be met:
+- The firewall must allow traffic coming from our resolver (identified via GID) to the configured
+ upstream resolvers. The firewall must have a list of IPs for which traffic will be allowed to
+ pass. The list will be populated by the resolved A and AAAA records, and reset when the tunnel
+ state machine moves away from the error state. This list will be cleared when moving to any other
+ tunnel state.
+- The daemon must configure the system to use the filtering resolver.
+- The resolver must only reply to queries when it's in an active state and it must only reply to
+ allowed queries. For now, only queries for `captive.apple.com` are allowed.
+- The daemon should keep track of *if* the user has enabled the filtering resolver. If the user
+ enables the custom resolver but something is already listening on port 53, then this should be
+ reported back to the front-ends. The user needs to know that the filtering resolver failed to run.
+
+
+## Filtering resolver's behavior
+
+The functionality of this feature is strongly tied to the states of the app when it's blocking
+traffic. These blocking states include the app when it's in the disconnected mode with _always
+require vpn_ turned on or in an error state with a blocking reason that isn't related to setting DNS
+or starting the filtering resolver. In all other tunnel states, the filtering resolver and firewall
+rules shouldn't be affected by this feature.
+
+### When the network-check leak is toggled on
+
+- When in a blocking state:
+ 1. Exclude the local resolver's traffic from the firewall.
+ 1. Configure the filtering resolver to bind to port 53.
+ 1. Read the system's current DNS config and configure the filtering resolver to use it.
+ 1. Configure the host to use our local resolver
+- In all other states, the filtering resolver should bind to port 53.
+
+If any of the above steps fail, the app should report the failure to the frontend that toggled the
+setting.
+
+### When the network-check leak is toggled off
+- When in a blocking state:
+ 1. If the host's DNS config is currently using our resolver, this should be reverted.
+ 1. The firewall should be reset to not allow the resolver traffic and the resolved IP traffic
+ through.
+ 1. The filtering resolver should be shut down, unbinding from port 53.
+- In all other states, the filtering resolver should be shut down, to leave port 53 free.
+
+### When the network-check leak is enabled
+#### Behavior when the daemon enters a blocking state
+To enable the filtering resolver when entering the error state the daemon should do the following:
+1. Exclude the local resolver's traffic from the firewall.
+1. Read the system's current DNS config and configure the filtering resolver to use it.
+1. Configure the host to use our local resolver.
+
+If any of the above steps fail, and the daemon is not in the disconnected state, it should
+transition to an error state and not attempt to start the filtering resolver again.
+
+#### Resolver's behavior when receiving a DNS query
+- When the daemon is in a blocking state, and the query is allowed:
+ 1. The query should be forwarded to the upstream resolvers
+ 1. When receiving the response, it's `A` and `AAAA` records should be allowed through the firewall.
+ 1. The response should be forwarded to the original requester.
+- Otherwise, if the network-check allowing is enabled, the response should be ignored. If the
+ option is disabled, it shouldn't be possible to receive a DNS query.
+
+#### When the daemon leaves a blocking state:
+- The host's DNS configuration is reverted to no longer use the filtering resolver.
+- The list of IP addresses that are allowed to pass through our firewall are cleared.
+
diff --git a/docs/security.md b/docs/security.md
index 67d4f3dcdf..8f1986642c 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -239,6 +239,19 @@ The intended use case for this setting is when the user want to only switch betw
connectivity at all and using VPN. With this setting active, the device can never communicate
with the internet outside of a VPN tunnel.
+### macOS network-check
+
+macOS needs to do a connectivity check before the daemon is able to connect to a tunnel, but the
+connectivity check will fail in the blocked state imposing a hefty timeout before a tunnel can be
+connected. The connectivity check requires a working DNS resolver and access to `captive.apple.com`.
+The feature is discussed in detail [here](allow-macos-network-check.md).
+
+The app has an option to allow the network check to leak in the error state and during the
+disconnected state if _Always require VPN_ is enabled. When the option is enabled, the firewall will
+allow all DNS traffic coming from a mullvad specific unix group, and it will allow all traffic to a
+set of resolved IP addresses coming from root (as identified by a unix user ID of `0`).
+
+
## DNS
DNS is treated a bit differently from other protocols. Since a user's DNS history can give a
@@ -256,6 +269,7 @@ The above holds during the [connected] state. In the [disconnected]
state the app does nothing with DNS, meaning the default one is used, probably from the ISP.
In the other states DNS is simply blocked.
+
## Desktop system service
On all desktop platforms the VPN tunnel and the device security is handled by a system
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index eb403901d0..faf84e7b08 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -620,6 +620,10 @@ msgctxt "navigation-bar"
msgid "Settings"
msgstr ""
+msgctxt "notifications"
+msgid " Unable to activate macOS network check module. Close any programs that might be using port 53, or disable \"Allow macOS network check\"."
+msgstr ""
+
#. The system notification displayed to the user when the account credit is close to expiry.
#. Available placeholder:
#. %(duration)s - remaining time, e.g. "2 days"
@@ -678,6 +682,10 @@ msgid "Disconnected and unsecure"
msgstr ""
msgctxt "notifications"
+msgid "Failed to read system DNS configuration."
+msgstr ""
+
+msgctxt "notifications"
msgid "Reconnecting"
msgstr ""
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
index c12ee53eff..a2416e9943 100644
--- a/gui/src/main/daemon-rpc.ts
+++ b/gui/src/main/daemon-rpc.ts
@@ -827,6 +827,10 @@ function convertFromTunnelStateErrorCause(
}
case grpcTypes.ErrorState.Cause.SPLIT_TUNNEL_ERROR:
return { reason: 'split_tunnel_error' };
+ case grpcTypes.ErrorState.Cause.FILTERING_RESOLVER_ERROR:
+ return { reason: 'filtering_resolver_error' };
+ case grpcTypes.ErrorState.Cause.READ_SYSTEM_DNS_CONFIG:
+ return { reason: 'read_system_dns_config' };
case grpcTypes.ErrorState.Cause.VPN_PERMISSION_DENIED:
// VPN_PERMISSION_DENIED is only ever created on Android
throw invalidErrorStateCause;
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index 8e42147f43..300af97660 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -40,6 +40,8 @@ export type ErrorStateCause =
| 'set_dns_error'
| 'start_tunnel_error'
| 'is_offline'
+ | 'filtering_resolver_error'
+ | 'read_system_dns_config'
| 'split_tunnel_error';
}
| { reason: 'set_firewall_policy_error'; details: FirewallPolicyError }
diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts
index 066c9c333e..22fd1858dc 100644
--- a/gui/src/shared/notifications/error.ts
+++ b/gui/src/shared/notifications/error.ts
@@ -138,6 +138,16 @@ function getMessage(errorDetails: IErrorState, accountExpiry?: string): string {
'notifications',
"Your device is offline. Try connecting when it's back online.",
);
+ case 'filtering_resolver_error':
+ // TODO: Figure out a better error message to show to users
+ return messages.pgettext(
+ 'notifications',
+ ' Unable to activate macOS network check module. Close any programs that might be using port 53, or disable "Allow macOS network check".',
+ );
+ case 'read_system_dns_config':
+ // TODO: Figure out a better error message to show to users
+ return messages.pgettext('notifications', 'Failed to read system DNS configuration.');
+
case 'split_tunnel_error':
return messages.pgettext(
'notifications',
diff --git a/mullvad-cli/src/cmds/mod.rs b/mullvad-cli/src/cmds/mod.rs
index 2ceb3bfdcf..1f0e98f8ed 100644
--- a/mullvad-cli/src/cmds/mod.rs
+++ b/mullvad-cli/src/cmds/mod.rs
@@ -28,6 +28,11 @@ pub use self::dns::Dns;
mod lan;
pub use self::lan::Lan;
+#[cfg(target_os = "macos")]
+mod network_check;
+#[cfg(target_os = "macos")]
+pub use self::network_check::NetworkCheck;
+
mod reconnect;
pub use self::reconnect::Reconnect;
@@ -64,6 +69,8 @@ pub fn get_commands() -> HashMap<&'static str, Box<dyn Command>> {
Box::new(Dns),
Box::new(Reconnect),
Box::new(Lan),
+ #[cfg(any(target_os = "macos"))]
+ Box::new(NetworkCheck),
Box::new(Relay),
Box::new(Reset),
#[cfg(any(target_os = "linux", windows))]
diff --git a/mullvad-cli/src/cmds/network_check.rs b/mullvad-cli/src/cmds/network_check.rs
new file mode 100644
index 0000000000..323e5eb512
--- /dev/null
+++ b/mullvad-cli/src/cmds/network_check.rs
@@ -0,0 +1,73 @@
+use crate::{new_rpc_client, Command, Result};
+use clap::value_t_or_exit;
+
+pub struct NetworkCheck;
+
+const SUBCOMMAND_DESCRIPTION: &'static str =
+"Control the macOS network check setting. Allowing the check leaks DNS queries for `captive.apple.com`. Allowing the
+connectivity check allows macOS to get online quicker after sleep and after connecting to new WiFi networks";
+
+#[mullvad_management_interface::async_trait]
+impl Command for NetworkCheck {
+ fn name(&self) -> &'static str {
+ "macos-network-check"
+ }
+
+ fn clap_subcommand(&self) -> clap::App<'static, 'static> {
+ clap::SubCommand::with_name(self.name())
+ .about(SUBCOMMAND_DESCRIPTION)
+ .setting(clap::AppSettings::SubcommandRequiredElseHelp)
+ .subcommand(
+ clap::SubCommand::with_name("set")
+ .about("Toggle macOS network check setting")
+ .arg(
+ clap::Arg::with_name("policy")
+ .required(true)
+ .possible_values(&["allow", "block"]),
+ ),
+ )
+ .subcommand(
+ clap::SubCommand::with_name("get")
+ .about("Display current macOS network check setting"),
+ )
+ }
+
+ async fn run(&self, matches: &clap::ArgMatches<'_>) -> Result<()> {
+ if let Some(set_matches) = matches.subcommand_matches("set") {
+ let allow_network_check = value_t_or_exit!(set_matches.value_of("policy"), String);
+ self.set(allow_network_check == "allow").await
+ } else if let Some(_get_matches) = matches.subcommand_matches("get") {
+ self.get().await
+ } else {
+ unreachable!("No macOS network check given")
+ }
+ }
+}
+
+impl NetworkCheck {
+ async fn set(&self, allow_network_check: bool) -> Result<()> {
+ let mut rpc = new_rpc_client().await?;
+ rpc.set_allow_macos_network_check(allow_network_check)
+ .await?;
+ println!("Changed macOS network check setting");
+ Ok(())
+ }
+
+ async fn get(&self) -> Result<()> {
+ let mut rpc = new_rpc_client().await?;
+ let allow_network_check = rpc
+ .get_settings(())
+ .await?
+ .into_inner()
+ .allow_macos_network_check;
+ println!(
+ "macOS network check setting: {}",
+ if allow_network_check {
+ "allow"
+ } else {
+ "block"
+ }
+ );
+ Ok(())
+ }
+}
diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml
index 8a64d731bc..dcdd0bf39e 100644
--- a/mullvad-daemon/Cargo.toml
+++ b/mullvad-daemon/Cargo.toml
@@ -12,6 +12,7 @@ cfg-if = "1.0"
chrono = { version = "0.4", features = ["serde"] }
clap = "2.25"
err-derive = "0.3.0"
+either = "1"
fern = { version = "0.6", features = ["colored"] }
futures = "0.3"
ipnetwork = "0.16"
diff --git a/mullvad-daemon/src/exclusion_gid.rs b/mullvad-daemon/src/exclusion_gid.rs
new file mode 100644
index 0000000000..ec87c5a7c6
--- /dev/null
+++ b/mullvad-daemon/src/exclusion_gid.rs
@@ -0,0 +1,53 @@
+use std::{ffi::CStr, io};
+/// name of the group that should be excluded
+const EXCLUSION_GROUP: &[u8] = b"mullvad-exclusion\0";
+
+/// Returns the GID of `mullvad-exclusion` group if it exists.
+pub fn get_exclusion_gid() -> io::Result<u32> {
+ let exclusion_group_name = CStr::from_bytes_with_nul(EXCLUSION_GROUP).unwrap();
+ get_group_id(exclusion_group_name)
+}
+
+/// Attempts to set the GID of the current process to `mullvad-exclusion`.
+pub fn set_exclusion_gid() -> io::Result<u32> {
+ let gid = get_exclusion_gid()?;
+ set_gid(gid)?;
+ Ok(gid)
+}
+
+#[cfg(test)]
+mod test {
+ #[test]
+ fn test_exclusion_gid() {
+ let _ = super::get_exclusion_gid();
+ }
+}
+
+/// Returns the GID of the specified group name
+fn get_group_id(group_name: &CStr) -> io::Result<u32> {
+ // SAFETY: group_name is a valid CString
+ let group = unsafe { libc::getgrnam(group_name.as_ptr() as *const _) };
+ if group.is_null() {
+ return Err(io::Error::from(io::ErrorKind::NotFound));
+ }
+ // SAFETY: group is not null
+ let gid = unsafe { (*group).gr_gid };
+ Ok(gid)
+}
+
+/// Sets group ID for the current process
+fn set_gid(gid: u32) -> io::Result<()> {
+ if unsafe { libc::setgid(gid) } == 0 {
+ Ok(())
+ } else {
+ Err(io::Error::last_os_error())
+ }
+}
+
+#[cfg(test)]
+#[test]
+fn test_unknown_group() {
+ let unknown_group = CStr::from_bytes_with_nul(b"asdunknown\0").unwrap();
+ let group_err = get_group_id(unknown_group).unwrap_err();
+ assert!(group_err.kind() == io::ErrorKind::NotFound)
+}
diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs
index 5557590b09..58fa2469d4 100644
--- a/mullvad-daemon/src/lib.rs
+++ b/mullvad-daemon/src/lib.rs
@@ -7,6 +7,8 @@ extern crate serde;
mod account;
pub mod account_history;
pub mod exception_logging;
+#[cfg(target_os = "macos")]
+pub mod exclusion_gid;
mod geoip;
pub mod logging;
#[cfg(not(target_os = "android"))]
@@ -20,6 +22,8 @@ pub mod settings;
pub mod version;
mod version_check;
+#[cfg(target_os = "macos")]
+use either::Either;
use futures::{
channel::{mpsc, oneshot},
future::{abortable, AbortHandle, Future},
@@ -186,6 +190,10 @@ pub enum Error {
#[error(display = "Failed to open cached target tunnel state")]
OpenCachedTargetState(#[error(source)] io::Error),
+
+ #[cfg(target_os = "macos")]
+ #[error(display = "Failed to set exclusion group")]
+ GroupIdError(#[error(source)] io::Error),
}
/// Enum representing commands that can be sent to the daemon.
@@ -239,6 +247,12 @@ pub enum DaemonCommand {
SetEnableIpv6(ResponseTx<(), settings::Error>, bool),
/// Set DNS options or servers to use
SetDnsOptions(ResponseTx<(), settings::Error>, DnsOptions),
+ /// Toggle macOS network check leak
+ #[cfg(target_os = "macos")]
+ SetAllowMacosNetworkCheck(
+ ResponseTx<(), Either<settings::Error, talpid_core::resolver::Error>>,
+ bool,
+ ),
/// Set MTU for wireguard tunnels
SetWireguardMtu(ResponseTx<(), settings::Error>, Option<u16>),
/// Set automatic key rotation interval for wireguard tunnels
@@ -555,6 +569,12 @@ where
command_channel: DaemonCommandChannel,
#[cfg(target_os = "android")] android_context: AndroidContext,
) -> Result<Self, Error> {
+ #[cfg(target_os = "macos")]
+ let exclusion_gid = {
+ bump_filehandle_limit();
+ exclusion_gid::set_exclusion_gid().map_err(Error::GroupIdError)?
+ };
+
let (tunnel_state_machine_shutdown_tx, tunnel_state_machine_shutdown_signal) =
oneshot::channel();
let runtime = tokio::runtime::Handle::current();
@@ -663,6 +683,10 @@ where
internal_event_tx.to_specialized_sender(),
offline_state_tx,
tunnel_state_machine_shutdown_tx,
+ #[cfg(target_os = "macos")]
+ exclusion_gid,
+ #[cfg(target_os = "macos")]
+ settings.allow_macos_network_check,
#[cfg(target_os = "android")]
android_context,
)
@@ -1234,6 +1258,11 @@ where
SetBridgeState(tx, bridge_state) => self.on_set_bridge_state(tx, bridge_state).await,
SetEnableIpv6(tx, enable_ipv6) => self.on_set_enable_ipv6(tx, enable_ipv6).await,
SetDnsOptions(tx, dns_servers) => self.on_set_dns_options(tx, dns_servers).await,
+ #[cfg(target_os = "macos")]
+ SetAllowMacosNetworkCheck(tx, enable_custom_resolver) => {
+ self.on_set_allow_macos_network_check(tx, enable_custom_resolver)
+ .await
+ }
SetWireguardMtu(tx, mtu) => self.on_set_wireguard_mtu(tx, mtu).await,
SetWireguardRotationInterval(tx, interval) => {
self.on_set_wireguard_rotation_interval(tx, interval).await
@@ -2233,6 +2262,50 @@ where
}
}
+ #[cfg(target_os = "macos")]
+ async fn on_set_allow_macos_network_check(
+ &mut self,
+ tx: ResponseTx<(), Either<settings::Error, talpid_core::resolver::Error>>,
+ enable_custom_resolver: bool,
+ ) {
+ let result = self
+ .on_set_custom_resolver_inner(enable_custom_resolver)
+ .await;
+
+ Self::oneshot_send(tx, result, "on_set_allow_macos_network_check resposne");
+ }
+
+ #[cfg(target_os = "macos")]
+ async fn on_set_custom_resolver_inner(
+ &mut self,
+ allow_macos_network_check: bool,
+ ) -> Result<(), Either<settings::Error, talpid_core::resolver::Error>> {
+ let (start_tx, start_rx) = oneshot::channel();
+ self.send_tunnel_command(TunnelCommand::AllowMacosNetworkCheck(
+ allow_macos_network_check,
+ start_tx,
+ ));
+ match start_rx.await {
+ Ok(result) => {
+ result.map_err(Either::Right)?;
+ }
+ Err(_) => {
+ log::error!("Tunnel state machine has exited");
+ return Ok(());
+ }
+ };
+ let settings_changed = self
+ .settings
+ .set_allow_macos_network_check(allow_macos_network_check)
+ .await
+ .map_err(Either::Left)?;
+ if settings_changed {
+ self.event_listener
+ .notify_settings(self.settings.to_settings());
+ }
+ Ok(())
+ }
+
async fn on_set_wireguard_mtu(
&mut self,
tx: ResponseTx<(), settings::Error>,
@@ -2668,3 +2741,40 @@ impl TunnelParametersGenerator for MullvadTunnelParametersGenerator {
}
}
}
+
+/// Bump filehandle limit
+#[cfg(target_os = "macos")]
+pub fn bump_filehandle_limit() {
+ let mut limits = libc::rlimit {
+ rlim_cur: 0,
+ rlim_max: 0,
+ };
+ // SAFETY: `&mut limits` is a valid pointer parameter for the getrlimit syscall
+ let status = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) };
+ if status != 0 {
+ log::error!(
+ "Failed to get file handle limits: {}-{}",
+ io::Error::from_raw_os_error(status),
+ status
+ );
+ return;
+ }
+
+ const INCREASED_FILEHANDLE_LIMIT: u64 = 1024;
+ // if file handle limit is already big enough, there's no reason to decrease it.
+ if limits.rlim_cur >= INCREASED_FILEHANDLE_LIMIT {
+ return;
+ }
+
+ limits.rlim_cur = INCREASED_FILEHANDLE_LIMIT;
+ // SAFETY: `&limits` is a valid pointer parameter for the getrlimit syscall
+ let status = unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limits) };
+ if status != 0 {
+ log::error!(
+ "Failed to set file handle limit to {}: {}-{}",
+ INCREASED_FILEHANDLE_LIMIT,
+ io::Error::from_raw_os_error(status),
+ status
+ );
+ }
+}
diff --git a/mullvad-daemon/src/logging.rs b/mullvad-daemon/src/logging.rs
index 1fd4b026c6..6c5aeb98d6 100644
--- a/mullvad-daemon/src/logging.rs
+++ b/mullvad-daemon/src/logging.rs
@@ -40,6 +40,9 @@ pub const SILENCED_CRATES: &[&str] = &[
"rustls",
"netlink_sys",
"tracing",
+ "trust_dns_proto",
+ "trust_dns_server",
+ "trust_dns_resolver",
];
const SLIGHTLY_SILENCED_CRATES: &[&str] = &["mnl", "nftnl"];
diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs
index 222e088193..e27f5fd506 100644
--- a/mullvad-daemon/src/management_interface.rs
+++ b/mullvad-daemon/src/management_interface.rs
@@ -1,4 +1,6 @@
use crate::{account_history, settings, DaemonCommand, DaemonCommandSender, EventListener};
+#[cfg(target_os = "macos")]
+use either::Either;
use futures::{
channel::{mpsc, oneshot},
StreamExt,
@@ -360,11 +362,41 @@ impl ManagementService for ManagementServiceImpl {
.map(Response::new)
.map_err(map_settings_error)
}
+
#[cfg(target_os = "android")]
async fn set_dns_options(&self, _: Request<types::DnsOptions>) -> ServiceResult<()> {
Ok(Response::new(()))
}
+ #[cfg(target_os = "macos")]
+ async fn set_allow_macos_network_check(&self, request: Request<bool>) -> ServiceResult<()> {
+ let allow_macos_network_check = request.into_inner();
+ log::debug!(
+ "set_allow_macos_network_check({:?})",
+ allow_macos_network_check
+ );
+
+ let (tx, rx) = oneshot::channel();
+ self.send_command_to_daemon(DaemonCommand::SetAllowMacosNetworkCheck(
+ tx,
+ allow_macos_network_check,
+ ))?;
+ self.wait_for_result(rx)
+ .await?
+ .map(Response::new)
+ .map_err(|err| match err {
+ Either::Right(resolver_error) => {
+ Status::new(Code::Internal, resolver_error.to_string())
+ }
+ Either::Left(settings_error) => map_settings_error(settings_error),
+ })
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ async fn set_allow_macos_network_check(&self, _: Request<bool>) -> ServiceResult<()> {
+ Ok(Response::new(()))
+ }
+
// Account management
//
diff --git a/mullvad-daemon/src/settings.rs b/mullvad-daemon/src/settings.rs
index 455b1775ed..0465185086 100644
--- a/mullvad-daemon/src/settings.rs
+++ b/mullvad-daemon/src/settings.rs
@@ -238,6 +238,18 @@ impl SettingsPersister {
self.update(should_save).await
}
+ #[cfg(target_os = "macos")]
+ pub async fn set_allow_macos_network_check(
+ &mut self,
+ allow_macos_network_check: bool,
+ ) -> Result<bool, Error> {
+ let should_save = Self::update_field(
+ &mut self.settings.allow_macos_network_check,
+ allow_macos_network_check,
+ );
+ self.update(should_save).await
+ }
+
pub async fn set_wireguard_mtu(&mut self, mtu: Option<u16>) -> Result<bool, Error> {
let should_save =
Self::update_field(&mut self.settings.tunnel_options.wireguard.options.mtu, mtu);
diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto
index a259b2f51a..3c7c0fe2e1 100644
--- a/mullvad-management-interface/proto/management_interface.proto
+++ b/mullvad-management-interface/proto/management_interface.proto
@@ -41,6 +41,7 @@ service ManagementService {
rpc SetWireguardMtu(google.protobuf.UInt32Value) returns (google.protobuf.Empty) {}
rpc SetEnableIpv6(google.protobuf.BoolValue) returns (google.protobuf.Empty) {}
rpc SetDnsOptions(DnsOptions) returns (google.protobuf.Empty) {}
+ rpc SetAllowMacosNetworkCheck(google.protobuf.BoolValue) returns (google.protobuf.Empty) {}
// Account management
rpc CreateNewAccount(google.protobuf.Empty) returns (google.protobuf.StringValue) {}
@@ -110,6 +111,8 @@ message ErrorState {
IS_OFFLINE = 6;
VPN_PERMISSION_DENIED = 7;
SPLIT_TUNNEL_ERROR = 8;
+ FILTERING_RESOLVER_ERROR = 9;
+ READ_SYSTEM_DNS_CONFIG = 10;
}
enum GenerationError {
@@ -272,6 +275,7 @@ message Settings {
TunnelOptions tunnel_options = 8;
bool show_beta_releases = 9;
SplitTunnelSettings split_tunnel = 10;
+ bool allow_macos_network_check = 11;
}
message SplitTunnelSettings {
diff --git a/mullvad-management-interface/src/types.rs b/mullvad-management-interface/src/types.rs
index 1504abee39..43925d0b91 100644
--- a/mullvad-management-interface/src/types.rs
+++ b/mullvad-management-interface/src/types.rs
@@ -149,6 +149,14 @@ impl From<mullvad_types::states::TunnelState> for TunnelState {
talpid_tunnel::ErrorStateCause::SplitTunnelError => {
i32::from(Cause::SplitTunnelError)
}
+ #[cfg(target_os = "macos")]
+ talpid_tunnel::ErrorStateCause::FilteringResolverError => {
+ i32::from(Cause::FilteringResolverError)
+ }
+ #[cfg(target_os = "macos")]
+ talpid_tunnel::ErrorStateCause::ReadSystemDnsConfig => {
+ i32::from(Cause::ReadSystemDnsConfig)
+ }
},
blocking_error: error_state.block_failure().map(map_firewall_error),
auth_fail_reason: if let talpid_tunnel::ErrorStateCause::AuthFailed(
@@ -386,6 +394,11 @@ impl From<&mullvad_types::settings::Settings> for Settings {
#[cfg(not(windows))]
let split_tunnel = None;
+ #[cfg(not(target_os = "macos"))]
+ let allow_macos_network_check = false;
+ #[cfg(target_os = "macos")]
+ let allow_macos_network_check = settings.allow_macos_network_check;
+
Self {
account_token: settings.get_account_token().unwrap_or_default(),
relay_settings: Some(RelaySettings::from(settings.get_relay_settings())),
@@ -397,6 +410,7 @@ impl From<&mullvad_types::settings::Settings> for Settings {
tunnel_options: Some(TunnelOptions::from(&settings.tunnel_options)),
show_beta_releases: settings.show_beta_releases,
split_tunnel,
+ allow_macos_network_check,
}
}
}
diff --git a/mullvad-setup/src/main.rs b/mullvad-setup/src/main.rs
index f59cc46a0b..1e901edb71 100644
--- a/mullvad-setup/src/main.rs
+++ b/mullvad-setup/src/main.rs
@@ -160,6 +160,8 @@ async fn reset_firewall() -> Result<(), Error> {
let mut firewall = Firewall::new(FirewallArguments {
initial_state: InitialFirewallState::None,
allow_lan: true,
+ #[cfg(target_os = "macos")]
+ exclusion_gid: 0,
})
.map_err(Error::FirewallError)?;
diff --git a/mullvad-types/src/settings/mod.rs b/mullvad-types/src/settings/mod.rs
index b2c9a36f0c..0705764555 100644
--- a/mullvad-types/src/settings/mod.rs
+++ b/mullvad-types/src/settings/mod.rs
@@ -79,6 +79,9 @@ pub struct Settings {
pub tunnel_options: TunnelOptions,
/// Whether to notify users of beta updates.
pub show_beta_releases: bool,
+ #[cfg(target_os = "macos")]
+ /// Allow leaking some traffic for macOS network check
+ pub allow_macos_network_check: bool,
/// Split tunneling settings
#[cfg(windows)]
pub split_tunnel: SplitTunnelSettings,
@@ -112,6 +115,8 @@ impl Default for Settings {
auto_connect: false,
tunnel_options: TunnelOptions::default(),
show_beta_releases: false,
+ #[cfg(target_os = "macos")]
+ allow_macos_network_check: false,
#[cfg(windows)]
split_tunnel: SplitTunnelSettings::default(),
settings_version: CURRENT_SETTINGS_VERSION,
diff --git a/talpid-core/Cargo.toml b/talpid-core/Cargo.toml
index 806bcc7145..42186f07b1 100644
--- a/talpid-core/Cargo.toml
+++ b/talpid-core/Cargo.toml
@@ -29,10 +29,11 @@ uuid = { version = "0.8", features = ["v4"] }
zeroize = "1"
chrono = "0.4"
tokio = { version = "1.8", features = [ "process", "rt-multi-thread", "fs" ] }
-tokio-stream = "0.1"
+tokio-stream = { version = "0.1", features = [ "io-util" ] }
rand = "0.7"
udp-over-tcp = { git = "https://github.com/mullvad/udp-over-tcp", rev = "1e27324362ed123b61fa2062b1599e5f9d569796" }
-
+trust-dns-server = { git = "https://github.com/mullvad/trust-dns", rev = "c782de0645335d1893a854337b965dd07790c068", features = [ "trust-dns-resolver" ] }
+socket2 = { git = "https://github.com/rust-lang/socket2", rev = "f7023b4c810eb7b0fedf23a1752461c41765c797", features = [ "all" ] }
[target.'cfg(not(target_os="android"))'.dependencies]
parity-tokio-ipc = "0.9"
@@ -63,11 +64,12 @@ mnl = { version = "0.2.0", features = ["mnl-1-0-4"] }
which = { version = "4.0", default-features = false }
tun = "0.5.1"
talpid-dbus = { path = "../talpid-dbus" }
-socket2 = { version = "0.4", features = ["all"] }
+# socket2 = { version = "0.4", features = ["all"] }
internet-checksum = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
+either = "1"
pfctl = "0.4.4"
system-configuration = "0.4"
tun = "0.5.1"
@@ -80,7 +82,7 @@ internet-checksum = "0.2"
widestring = "0.5"
winreg = { version = "0.7", features = ["transactions"] }
winapi = { version = "0.3.6", features = ["combaseapi", "handleapi", "ifdef", "libloaderapi", "netioapi", "psapi", "stringapiset", "synchapi", "tlhelp32", "winbase", "winioctl", "winuser"] }
-socket2 = { version = "0.4", features = ["all"] }
+# socket2 = { version = "0.4", features = ["all"] }
talpid-platform-metadata = { path = "../talpid-platform-metadata" }
memoffset = "0.6"
diff --git a/talpid-core/src/dns/macos.rs b/talpid-core/src/dns/macos.rs
index efb4e8c816..393de23dd9 100644
--- a/talpid-core/src/dns/macos.rs
+++ b/talpid-core/src/dns/macos.rs
@@ -1,10 +1,12 @@
+use crate::tunnel_state_machine::TunnelCommand;
+use futures::channel::mpsc;
use log::{debug, trace};
use parking_lot::Mutex;
use std::{
collections::HashMap,
fmt,
- net::IpAddr,
- sync::{mpsc, Arc},
+ net::{AddrParseError, IpAddr},
+ sync::{mpsc as sync_mpsc, Arc},
thread,
};
use system_configuration::{
@@ -17,7 +19,7 @@ use system_configuration::{
string::CFString,
},
dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext},
- sys::schema_definitions::kSCPropNetDNSServerAddresses,
+ sys::schema_definitions::{kSCPropNetDNSServerAddresses, kSCPropNetInterfaceDeviceName},
};
pub type Result<T> = std::result::Result<T, Error>;
@@ -32,6 +34,22 @@ pub enum Error {
/// Failed to initialize dynamic store
#[error(display = "Failed to initialize dynamic store")]
DynamicStoreInitError,
+
+ /// Failed to parse IP address from config string
+ #[error(display = "Failed to parse an IP address from a config string")]
+ AddrParseError(String, String, AddrParseError),
+
+ /// Failed to obtain name for interface
+ #[error(display = "Failed to obtain interface name")]
+ GetInterfaceNameError,
+
+ /// Failed to load interface config
+ #[error(display = "Failed to load interface config at path {}", _0)]
+ LoadInterfaceConfigError(String),
+
+ /// Failed to load DNS config
+ #[error(display = "Failed to load DNS config at path {}", _0)]
+ LoadDnsConfigError(String),
}
const STATE_PATH_PATTERN: &str = "State:/Network/Service/.*/DNS";
@@ -45,16 +63,38 @@ struct State {
dns_settings: DnsSettings,
/// The backup of all DNS settings. These are being applied back on reset.
backup: HashMap<ServicePath, Option<DnsSettings>>,
+ /// Tunnel command sender for reporting updates to the system DNS config
+ tunnel_tx: std::sync::Weak<mpsc::UnboundedSender<crate::tunnel_state_machine::TunnelCommand>>,
+}
+
+impl State {
+ fn send_new_config(&self) {
+ if let Some(tunnel_tx) = self.tunnel_tx.upgrade() {
+ match parse_sc_config(&self.backup) {
+ Ok(config) => {
+ // TODO: do better filtering to get the best resolver
+ let _ = tunnel_tx
+ .unbounded_send(TunnelCommand::HostDnsConfig(config.into_iter().next()));
+ }
+ Err(err) => {
+ log::error!("Failed to parse host's DNS config: {}", err);
+ }
+ };
+ }
+ }
}
/// Holds the configuration for one service.
-#[derive(Debug, Eq, PartialEq)]
-struct DnsSettings(CFDictionary);
+#[derive(Debug, Eq, PartialEq, Clone)]
+struct DnsSettings {
+ dict: CFDictionary,
+ name: String,
+}
unsafe impl Send for DnsSettings {}
impl DnsSettings {
- pub fn from_server_addresses(server_addresses: &[DnsServer]) -> Self {
+ pub fn from_server_addresses(server_addresses: &[DnsServer], name: String) -> Self {
let mut mut_dict = CFMutableDictionary::new();
if !server_addresses.is_empty() {
let cf_string_servers: Vec<CFString> =
@@ -67,16 +107,23 @@ impl DnsSettings {
&server_addresses_value.to_void(),
);
}
- let dict = unsafe { CFDictionary::wrap_under_get_rule(mut_dict.as_concrete_TypeRef()) };
- DnsSettings(dict)
+ let dict = mut_dict.to_immutable();
+ DnsSettings { dict, name }
}
/// Get DNS settings for a given service path. Returns `None` If the path does not exist.
- pub fn load<S: Into<CFString>>(store: &SCDynamicStore, path: S) -> Option<Self> {
+ pub fn load<S: Into<CFString>>(store: &SCDynamicStore, path: S) -> Result<Self> {
+ let cf_path = path.into();
+
let dict = store
- .get(path)
- .and_then(CFPropertyList::downcast_into::<CFDictionary>)?;
- Some(DnsSettings(dict))
+ .get(cf_path.clone())
+ .and_then(CFPropertyList::downcast_into::<CFDictionary>)
+ .ok_or(Error::LoadDnsConfigError(cf_path.to_string()))?;
+
+ let name =
+ InterfaceSettings::load_from_dns_key(store, cf_path.to_string())?.interface_name()?;
+
+ Ok(DnsSettings { dict, name })
}
/// Set the dynamic store entry at `path` to a dictionary these DNS settings.
@@ -90,7 +137,7 @@ impl DnsSettings {
self.server_addresses().join(", "),
path.to_string()
);
- if store.set(path, self.0.clone()) {
+ if store.set(path, self.dict.clone()) {
Ok(())
} else {
Err(Error::SettingDnsFailed)
@@ -98,7 +145,7 @@ impl DnsSettings {
}
pub fn server_addresses(&self) -> Vec<String> {
- self.0
+ self.dict
.find(unsafe { kSCPropNetDNSServerAddresses }.to_void())
.map(|array_ptr| unsafe { CFType::wrap_under_get_rule(*array_ptr) })
.and_then(|array| array.downcast::<CFArray>())
@@ -106,6 +153,20 @@ impl DnsSettings {
.unwrap_or(Vec::new())
}
+ pub fn interface_config(&self, interface_path: &str) -> Result<Vec<IpAddr>> {
+ let addresses = self
+ .server_addresses()
+ .into_iter()
+ .map(|server_addr| {
+ server_addr.parse().map_err(|err| {
+ Error::AddrParseError(interface_path.to_string(), server_addr.clone(), err)
+ })
+ })
+ .collect::<Result<Vec<IpAddr>>>()?;
+
+ Ok(addresses)
+ }
+
/// Parses a CFArray into a Rust vector of Rust strings, if the array contains CFString
/// instances only, otherwise `None` is returned.
fn parse_cf_array_to_strings(array: CFArray) -> Option<Vec<String>> {
@@ -123,6 +184,39 @@ impl DnsSettings {
}
}
+#[derive(Debug, Eq, PartialEq)]
+struct InterfaceSettings(CFDictionary);
+
+impl InterfaceSettings {
+ /// Get network interface settings for the given path
+ pub fn load_from_dns_key(store: &SCDynamicStore, dns_path: String) -> Result<Self> {
+ // remove the "DNS" part of the path
+ let path = match dns_path.strip_prefix("State") {
+ Some(service_path) => "Setup".to_owned() + service_path,
+ None => dns_path.to_string(),
+ };
+ let interface_path = path.replace("/DNS", "/Interface");
+
+ Ok(Self(
+ store
+ .get(CFString::from(interface_path.as_str()))
+ .and_then(CFPropertyList::downcast_into::<CFDictionary>)
+ .ok_or(Error::LoadInterfaceConfigError(path))?,
+ ))
+ }
+
+ pub fn interface_name(&self) -> Result<String> {
+ self.0
+ .find(unsafe { kSCPropNetInterfaceDeviceName }.to_void())
+ .map(|str_pointer| unsafe { CFType::wrap_under_get_rule(*str_pointer) })
+ .and_then(|string| string.downcast::<CFString>())
+ .map(|cf_string| cf_string.to_string())
+ .ok_or(Error::GetInterfaceNameError)
+ }
+}
+
+unsafe impl Send for InterfaceSettings {}
+
pub struct DnsMonitor {
store: SCDynamicStore,
@@ -130,6 +224,8 @@ pub struct DnsMonitor {
/// When it's `Some(state)` we are actively making sure `state.dns_settings` is configured
/// on all network interfaces.
state: Arc<Mutex<Option<State>>>,
+
+ tunnel_tx: std::sync::Weak<mpsc::UnboundedSender<crate::tunnel_state_machine::TunnelCommand>>,
}
/// SAFETY: The `SCDynamicStore` can be sent to other threads since it doesn't share mutable state
@@ -143,18 +239,19 @@ impl super::DnsMonitorT for DnsMonitor {
/// DNS settings for all network interfaces. If any changes occur it will instantly reset
/// the DNS settings for that interface back to the last server list set to this instance
/// with `set_dns`.
- fn new() -> Result<Self> {
+ fn new(tunnel_tx: std::sync::Weak<mpsc::UnboundedSender<TunnelCommand>>) -> Result<Self> {
let state = Arc::new(Mutex::new(None));
Self::spawn(state.clone())?;
Ok(DnsMonitor {
store: SCDynamicStoreBuilder::new("mullvad-dns").build(),
state,
+ tunnel_tx,
})
}
- fn set(&mut self, _interface: &str, servers: &[IpAddr]) -> Result<()> {
+ fn set(&mut self, interface: &str, servers: &[IpAddr]) -> Result<()> {
let servers: Vec<DnsServer> = servers.iter().map(|ip| ip.to_string()).collect();
- let settings = DnsSettings::from_server_addresses(&servers);
+ let settings = DnsSettings::from_server_addresses(&servers, interface.to_string());
let mut state_lock = self.state.lock();
*state_lock = Some(match state_lock.take() {
None => {
@@ -166,6 +263,7 @@ impl super::DnsMonitorT for DnsMonitor {
State {
dns_settings: settings,
backup,
+ tunnel_tx: self.tunnel_tx.clone(),
}
}
Some(state) => {
@@ -176,6 +274,7 @@ impl super::DnsMonitorT for DnsMonitor {
State {
dns_settings: settings,
backup: state.backup,
+ tunnel_tx: self.tunnel_tx.clone(),
}
} else {
debug!("No change, new DNS same as the one already set");
@@ -209,7 +308,7 @@ impl DnsMonitor {
/// Spawns the background thread running the CoreFoundation main loop and monitors the system
/// for DNS changes.
fn spawn(state: Arc<Mutex<Option<State>>>) -> Result<()> {
- let (result_tx, result_rx) = mpsc::channel();
+ let (result_tx, result_rx) = sync_mpsc::channel();
thread::spawn(move || match create_dynamic_store(state) {
Ok(store) => {
result_tx.send(Ok(())).unwrap();
@@ -221,6 +320,34 @@ impl DnsMonitor {
});
result_rx.recv().unwrap()
}
+ /// Get the system config without our changes
+ pub fn get_system_config(&self) -> Result<Option<(String, Vec<IpAddr>)>> {
+ self.state
+ .lock()
+ .as_ref()
+ .map(|state| parse_sc_config(&state.backup))
+ .unwrap_or_else(|| parse_sc_config(&read_all_dns(&self.store)))
+ }
+}
+
+fn parse_sc_config(
+ config: &HashMap<String, Option<DnsSettings>>,
+) -> Result<Option<(String, Vec<IpAddr>)>> {
+ config
+ .iter()
+ .filter_map(|(path, maybe_config)| {
+ if let Some(settings) = maybe_config {
+ Some((path, settings))
+ } else {
+ None
+ }
+ })
+ .map(|(path, settings)| {
+ let addresses = settings.interface_config(path.as_str())?;
+ Ok((settings.name.clone(), addresses))
+ })
+ .next()
+ .transpose()
}
/// Creates a `SCDynamicStore` that watches all network interfaces for changes to the DNS settings.
@@ -279,15 +406,16 @@ fn dns_change_callback_internal(
changed_keys: CFArray<CFString>,
state: &mut State,
) {
+ state.send_new_config();
for path in &changed_keys {
- let should_set_dns = match DnsSettings::load(&store, path.clone()) {
+ let should_set_dns = match DnsSettings::load(&store, path.clone()).ok() {
None => {
debug!("Detected DNS removed for {}", *path);
state.backup.insert(path.to_string(), None);
true
}
Some(new_settings) => {
- if new_settings != state.dns_settings {
+ if new_settings.dict != state.dns_settings.dict {
debug!("Detected DNS change for {}", *path);
state.backup.insert(path.to_string(), Some(new_settings));
true
@@ -307,7 +435,7 @@ fn dns_change_callback_internal(
if !state.backup.contains_key(&setup_path_str) {
state.backup.insert(
setup_path_str,
- DnsSettings::load(&store, setup_path.clone()),
+ DnsSettings::load(&store, setup_path.clone()).ok(),
);
}
if let Err(e) = state.dns_settings.save(&store, setup_path.clone()) {
@@ -326,10 +454,13 @@ fn read_all_dns(store: &SCDynamicStore) -> HashMap<ServicePath, Option<DnsSettin
for state_path in paths.iter() {
let state_path_str = state_path.to_string();
let setup_path_str = state_to_setup_path(&state_path_str).unwrap();
- backup.insert(state_path_str, DnsSettings::load(store, state_path.clone()));
+ backup.insert(
+ state_path_str,
+ DnsSettings::load(store, state_path.clone()).ok(),
+ );
backup.insert(
setup_path_str.clone(),
- DnsSettings::load(store, setup_path_str.as_ref()),
+ DnsSettings::load(store, setup_path_str.as_ref()).ok(),
);
}
}
@@ -338,7 +469,10 @@ fn read_all_dns(store: &SCDynamicStore) -> HashMap<ServicePath, Option<DnsSettin
for setup_path in paths.iter() {
let setup_path_str = setup_path.to_string();
if !backup.contains_key(&setup_path_str) {
- backup.insert(setup_path_str, DnsSettings::load(store, setup_path.clone()));
+ backup.insert(
+ setup_path_str,
+ DnsSettings::load(store, setup_path.clone()).ok(),
+ );
}
}
}
diff --git a/talpid-core/src/dns/mod.rs b/talpid-core/src/dns/mod.rs
index f60b38a862..5a7b5c0dec 100644
--- a/talpid-core/src/dns/mod.rs
+++ b/talpid-core/src/dns/mod.rs
@@ -33,6 +33,9 @@ impl DnsMonitor {
pub fn new(
#[cfg(target_os = "linux")] handle: tokio::runtime::Handle,
#[cfg(target_os = "linux")] route_manager: RouteManagerHandle,
+ #[cfg(target_os = "macos")] command_tx: std::sync::Weak<
+ futures::channel::mpsc::UnboundedSender<crate::tunnel_state_machine::TunnelCommand>,
+ >,
) -> Result<Self, Error> {
Ok(DnsMonitor {
inner: imp::DnsMonitor::new(
@@ -40,10 +43,19 @@ impl DnsMonitor {
handle,
#[cfg(target_os = "linux")]
route_manager,
+ #[cfg(target_os = "macos")]
+ command_tx,
)?,
})
}
+ /// Returns a map of interfaces and respective list of resolvers that don't contain our
+ /// changes.
+ #[cfg(target_os = "macos")]
+ pub fn get_system_config(&self) -> Result<Option<(String, Vec<IpAddr>)>, Error> {
+ self.inner.get_system_config()
+ }
+
/// Set DNS to the given servers. And start monitoring the system for changes.
pub fn set(&mut self, interface: &str, servers: &[IpAddr]) -> Result<(), Error> {
log::info!(
@@ -75,7 +87,11 @@ trait DnsMonitorT: Sized {
) -> Result<Self, Self::Error>;
#[cfg(not(target_os = "linux"))]
- fn new() -> Result<Self, Self::Error>;
+ fn new(
+ #[cfg(target_os = "macos")] command_tx: std::sync::Weak<
+ futures::channel::mpsc::UnboundedSender<crate::tunnel_state_machine::TunnelCommand>,
+ >,
+ ) -> Result<Self, Self::Error>;
fn set(&mut self, interface: &str, servers: &[IpAddr]) -> Result<(), Self::Error>;
diff --git a/talpid-core/src/firewall/macos.rs b/talpid-core/src/firewall/macos.rs
index 6cbdbbf7ff..a10f82bc69 100644
--- a/talpid-core/src/firewall/macos.rs
+++ b/talpid-core/src/firewall/macos.rs
@@ -2,6 +2,7 @@ use super::{FirewallArguments, FirewallPolicy, FirewallT};
use ipnetwork::IpNetwork;
use pfctl::{DropAction, FilterRuleAction, Uid};
use std::{
+ collections::BTreeSet,
env,
net::{IpAddr, Ipv4Addr},
};
@@ -23,12 +24,13 @@ pub struct Firewall {
pf: pfctl::PfCtl,
pf_was_enabled: Option<bool>,
rule_logging: RuleLogging,
+ exclusion_gid: u32,
}
impl FirewallT for Firewall {
type Error = Error;
- fn new(_args: FirewallArguments) -> Result<Self> {
+ fn new(args: FirewallArguments) -> Result<Self> {
// Allows controlling whether firewall rules should log to pflog0. Useful for debugging the
// rules.
let firewall_debugging = env::var("TALPID_FIREWALL_DEBUG");
@@ -44,6 +46,7 @@ impl FirewallT for Firewall {
pf: pfctl::PfCtl::new()?,
pf_was_enabled: None,
rule_logging,
+ exclusion_gid: args.exclusion_gid,
})
}
@@ -128,7 +131,7 @@ impl Firewall {
let mut rules = vec![];
for server in &dns_servers {
- rules.append(&mut self.get_allow_dns_rules(&tunnel, *server)?);
+ rules.append(&mut self.get_allow_dns_rules_when_connected(&tunnel, *server)?);
}
rules.push(self.get_allow_relay_rule(peer_endpoint)?);
@@ -148,20 +151,48 @@ impl Firewall {
FirewallPolicy::Blocked {
allow_lan,
allowed_endpoint,
+ allowed_ips,
+ allow_gid_exclusion_traffic,
} => {
let mut rules = Vec::new();
rules.push(self.get_allowed_endpoint_rule(allowed_endpoint.endpoint)?);
+
+ if allow_gid_exclusion_traffic {
+ rules.extend(self.get_allow_excluded_dns_rules()?);
+ rules.extend(self.get_exclusion_rules(&allowed_ips)?);
+ }
if allow_lan {
// Important to block DNS before allow LAN (so DNS does not leak to the LAN)
rules.append(&mut self.get_block_dns_rules()?);
rules.append(&mut self.get_allow_lan_rules()?);
}
+
Ok(rules)
}
}
}
- fn get_allow_dns_rules(
+ /// Constructs rules that allow DNS traffic coming from processes that belong to the excluded
+ /// group ID to leak.
+ fn get_allow_excluded_dns_rules(&self) -> Result<[pfctl::FilterRule; 2]> {
+ let mut builder = self.create_rule_builder(FilterRuleAction::Pass);
+
+ builder.direction(pfctl::Direction::Out);
+ builder.quick(true);
+ builder.keep_state(pfctl::StatePolicy::Keep);
+ builder.to(pfctl::Port::from(53));
+ builder.group(self.exclusion_gid);
+
+ Ok([
+ builder.proto(pfctl::Proto::Udp).build()?,
+ builder
+ .proto(pfctl::Proto::Tcp)
+ .tcp_flags(Self::get_tcp_flags())
+ .build()?,
+ ])
+ }
+
+ fn get_allow_dns_rules_when_connected(
&self,
tunnel: &crate::tunnel::TunnelMetadata,
server: IpAddr,
@@ -315,6 +346,27 @@ impl Firewall {
Ok(vec![lo0_rule])
}
+ /// Constructs firewall rules that allow traffic to a set of allowed IP addresses coming from
+ /// UID 0 processes to leak.
+ fn get_exclusion_rules(
+ &self,
+ allowed_ips: &BTreeSet<IpAddr>,
+ ) -> Result<Vec<pfctl::FilterRule>> {
+ let mut vec = Vec::with_capacity(allowed_ips.len());
+ for ip in allowed_ips.iter() {
+ vec.push(
+ self.create_rule_builder(FilterRuleAction::Pass)
+ .direction(pfctl::Direction::Out)
+ .to(*ip)
+ .quick(true)
+ .user(Uid::from(ROOT_UID))
+ .keep_state(pfctl::StatePolicy::Keep)
+ .build()?,
+ );
+ }
+ Ok(vec)
+ }
+
fn get_allow_lan_rules(&self) -> Result<Vec<pfctl::FilterRule>> {
let mut rules = vec![];
for net in &*super::ALLOWED_LAN_NETS {
diff --git a/talpid-core/src/firewall/mod.rs b/talpid-core/src/firewall/mod.rs
index 46bcf27745..80714d8338 100644
--- a/talpid-core/src/firewall/mod.rs
+++ b/talpid-core/src/firewall/mod.rs
@@ -2,6 +2,8 @@
use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
#[cfg(unix)]
use lazy_static::lazy_static;
+#[cfg(target_os = "macos")]
+use std::collections::BTreeSet;
use std::fmt;
#[cfg(not(target_os = "android"))]
use std::net::IpAddr;
@@ -136,6 +138,12 @@ pub enum FirewallPolicy {
allow_lan: bool,
/// Host that should be reachable while in the blocked state.
allowed_endpoint: AllowedEndpoint,
+ /// A list of IPs that can be reached outside the tunnel.
+ #[cfg(target_os = "macos")]
+ allowed_ips: BTreeSet<IpAddr>,
+ /// Enables specific GID exclusion traffic
+ #[cfg(target_os = "macos")]
+ allow_gid_exclusion_traffic: bool,
},
}
@@ -196,6 +204,7 @@ impl fmt::Display for FirewallPolicy {
FirewallPolicy::Blocked {
allow_lan,
allowed_endpoint,
+ ..
} => write!(
f,
"Blocked. {} LAN. Allowing endpoint {}",
@@ -218,6 +227,10 @@ pub struct FirewallArguments {
pub initial_state: InitialFirewallState,
/// This argument is required for the blocked state to configure the firewall correctly.
pub allow_lan: bool,
+ #[cfg(target_os = "macos")]
+ /// This argument is required on macOS to know which group's traffic should be excluded, if at
+ /// all.
+ pub exclusion_gid: u32,
}
/// State to enter during firewall init.
diff --git a/talpid-core/src/lib.rs b/talpid-core/src/lib.rs
index 8d540fcdbd..73c3293fb4 100644
--- a/talpid-core/src/lib.rs
+++ b/talpid-core/src/lib.rs
@@ -65,3 +65,7 @@ mod linux;
/// A pair of functions to monitor and establish connectivity with ICMP
pub mod ping_monitor;
+
+/// A resolver that's controlled by the tunnel state machine
+#[cfg(target_os = "macos")]
+pub mod resolver;
diff --git a/talpid-core/src/resolver/mod.rs b/talpid-core/src/resolver/mod.rs
new file mode 100644
index 0000000000..8e2731346b
--- /dev/null
+++ b/talpid-core/src/resolver/mod.rs
@@ -0,0 +1,822 @@
+use socket2::{Domain, Socket, Type};
+
+use std::{
+ collections::BTreeSet,
+ ffi::CString,
+ future::Future,
+ io,
+ net::{IpAddr, Ipv4Addr, SocketAddr},
+ pin::Pin,
+ str::FromStr,
+ sync::{Arc, Mutex, Weak},
+};
+
+#[cfg(target_os = "macos")]
+use std::{
+ net,
+ num::NonZeroU32,
+ os::unix::io::{FromRawFd, IntoRawFd, RawFd},
+};
+
+use futures::{
+ channel::{mpsc, oneshot},
+ future::Either,
+ SinkExt, StreamExt,
+};
+
+use crate::tunnel_state_machine::TunnelCommand;
+use trust_dns_server::{
+ authority::{
+ EmptyLookup, LookupObject, MessageRequest, MessageResponse, MessageResponseBuilder,
+ },
+ client::{
+ op::LowerQuery,
+ rr::{LowerName, RecordType},
+ },
+ proto::{
+ self,
+ iocompat::AsyncIoTokioAsStd,
+ op::{header::MessageType, op_code::OpCode, Header},
+ rr::{domain::Name, record_data::RData, Record},
+ TokioTime,
+ },
+ resolver::{
+ config::{NameServerConfigGroup, ResolverConfig, ResolverOpts},
+ error::ResolveError,
+ lookup::Lookup,
+ name_server::{GenericConnection, GenericConnectionProvider},
+ AsyncResolver,
+ },
+ server::{Request, RequestHandler, ResponseHandler, ResponseInfo},
+ ServerFuture,
+};
+
+const ALLOWED_RECORD_TYPES: &[RecordType] = &[RecordType::A, RecordType::AAAA, RecordType::CNAME];
+const CAPTIVE_PORTAL_DOMAIN: &str = "captive.apple.com";
+
+type TunnelCommandSender = Weak<mpsc::UnboundedSender<TunnelCommand>>;
+
+/// Starts a resolver. Returns a cloneable handle, which can activate, deactivate and shut down the
+/// resolver. When all instances of a handle are dropped, the server will stop.
+pub(crate) async fn start_resolver(sender: TunnelCommandSender) -> Result<ResolverHandle, Error> {
+ start_resolver_inner(sender, 53).await
+}
+
+async fn start_resolver_inner(
+ tunnel_tx: TunnelCommandSender,
+ port: u16,
+) -> Result<ResolverHandle, Error> {
+ let (resolver, resolver_handle) = FilteringResolver::new(tunnel_tx, port).await?;
+ tokio::spawn(resolver.run());
+ Ok(resolver_handle)
+}
+
+/// Resolver errors
+#[derive(err_derive::Error, Debug)]
+#[error(no_from)]
+pub enum Error {
+ /// Failed to launch resolver
+ #[error(display = "Failed to launch resolver")]
+ LaunchResolver(#[error(source)] ResolveError),
+
+ /// Failed to bind TCP socket
+ #[error(display = "Failed to bind TCP socket")]
+ TcpBindError(#[error(source)] io::Error),
+
+ /// Failed to bind UDP socket
+ #[error(display = "Failed to bind UDP socket")]
+ UdpBindError(#[error(source)] io::Error),
+
+ /// Launcher thread panicked
+ #[error(display = "Panic in the launcher thread")]
+ LauncherThreadPanic,
+
+ /// The resolver has already shut down
+ #[error(display = "Resolver is already shut down")]
+ ResolverShutdown,
+
+ /// System DNS error
+ #[error(display = "System DNS error")]
+ SystemDnsError(crate::dns::Error),
+}
+
+impl From<crate::dns::Error> for Error {
+ fn from(err: crate::dns::Error) -> Self {
+ Error::SystemDnsError(err)
+ }
+}
+
+/// A filtering resolver. Listens on a specified port for DNS queries and responds queries for
+/// `catpive.apple.com`. Can be toggled to unbind, be bound but not respond or bound and responding
+/// to some queries.
+struct FilteringResolver {
+ excluded_resolver: ExcludedUpstreamResolver,
+ rx: mpsc::Receiver<ResolverMessage>,
+ resolver_state: ResolverState,
+ tunnel_tx: TunnelCommandSender,
+ dns_server: Option<(tokio::task::JoinHandle<()>, oneshot::Receiver<()>)>,
+ command_sender: Weak<mpsc::Sender<ResolverMessage>>,
+ runtime_provider: RuntimeProvider,
+ port: u16,
+}
+
+type OurConnectionProvider = GenericConnectionProvider<RuntimeProvider>;
+type ExcludedUpstreamResolver = AsyncResolver<GenericConnection, OurConnectionProvider>;
+
+/// Resolver state
+#[derive(Debug, PartialEq, Clone)]
+enum ResolverState {
+ /// When in an active state, the resolver needs a set of upstream resolvers and the name of the
+ /// interface it should bind to.
+ Active(Option<(String, Vec<IpAddr>)>),
+ /// In the inactive state, the resolver is still listening for DNS queries but it won't be
+ /// responding to any of them
+ Inactive,
+ /// In the shutdown state the resolver is unbound and not listening to queries.
+ Shutdown,
+}
+
+impl ResolverState {
+ fn is_running(&self) -> bool {
+ match self {
+ Self::Active(_) => true,
+ _ => false,
+ }
+ }
+}
+
+/// The `FilteringResolver` is an actor responding to 2 types of messages: either it's a new DNS
+/// query or it's a message to toggle it's state.
+enum ResolverMessage {
+ /// A new DNS query coming in from listener.
+ Request(LowerQuery, oneshot::Sender<Box<dyn LookupObject>>),
+ /// Set the resolver's state.
+ SetResolverState(ResolverState, oneshot::Sender<Result<(), Error>>),
+}
+
+/// A handle to control a filtering resolver
+#[derive(Clone)]
+pub(crate) struct ResolverHandle {
+ tx: Arc<mpsc::Sender<ResolverMessage>>,
+}
+
+impl ResolverHandle {
+ fn new(tx: Arc<mpsc::Sender<ResolverMessage>>) -> Self {
+ Self { tx }
+ }
+
+ /// Activate the resolver to have it respond to allowed DNS queries.
+ pub async fn set_active(&self, config: Option<(String, Vec<IpAddr>)>) -> Result<(), Error> {
+ self.set_state(ResolverState::Active(config)).await
+ }
+
+ /// De-activate the resolver to have it ignore all DNS queries.
+ pub async fn set_inactive(&self) -> Result<(), Error> {
+ self.set_state(ResolverState::Inactive).await
+ }
+
+ /// Shut down the resolver so that it no longer listens on port 53.
+ pub async fn shutdown(&self) -> Result<(), Error> {
+ self.set_state(ResolverState::Shutdown).await
+ }
+
+ async fn set_state(&self, state: ResolverState) -> Result<(), Error> {
+ let (done_tx, done_rx) = oneshot::channel();
+ let tx: &mpsc::Sender<ResolverMessage> = &*self.tx;
+ let mut tx = tx.clone();
+ tx.send(ResolverMessage::SetResolverState(state, done_tx))
+ .await
+ .map_err(|_| Error::ResolverShutdown)?;
+
+ done_rx.await.map_err(|_| Error::ResolverShutdown)?
+ }
+}
+
+impl FilteringResolver {
+ /// Constructs a new filtering resolver and it's handle.
+ async fn new(
+ tunnel_tx: TunnelCommandSender,
+ port: u16,
+ ) -> Result<(Self, ResolverHandle), Error> {
+ let (tx, rx) = mpsc::channel(0);
+ let command_tx = Arc::new(tx);
+
+ let runtime_provider = RuntimeProvider::new();
+
+ let resolver_config = ResolverConfig::from_parts(
+ None,
+ vec![],
+ NameServerConfigGroup::from_ips_clear(&[], 53, false),
+ );
+ let resolver = ExcludedUpstreamResolver::new(
+ resolver_config.clone(),
+ ResolverOpts::default(),
+ runtime_provider.clone(),
+ )
+ .map_err(Error::LaunchResolver)?;
+
+ let resolver = Self {
+ excluded_resolver: resolver,
+ resolver_state: ResolverState::Shutdown,
+ rx,
+ tunnel_tx,
+ command_sender: Arc::downgrade(&command_tx),
+ dns_server: None,
+ runtime_provider,
+ port,
+ };
+
+ Ok((resolver, ResolverHandle::new(command_tx)))
+ }
+
+ /// Runs the filtering resolver as an actor, listening for new [ResolverMessage] instances.
+ /// When all related [ResolverHandle] instances are dropped, this function will return.
+ async fn run(mut self) {
+ use ResolverMessage::*;
+ while let Some(message) = self.rx.next().await {
+ match message {
+ Request(query, tx) => {
+ if self.resolver_state.is_running() {
+ tokio::spawn(self.resolve(query, tx));
+ }
+ }
+ SetResolverState(resolver_state, tx) => {
+ match resolver_state {
+ ResolverState::Shutdown => {
+ self.stop_server().await;
+ self.resolver_state = ResolverState::Shutdown;
+ }
+ running_state => {
+ if self.dns_server.is_none() {
+ if let Err(err) = self.spawn_new_server().await {
+ let _ = tx.send(Err(err));
+ let _ = self.reset_resolver().await;
+ continue;
+ }
+ }
+ self.resolver_state = running_state;
+ }
+ }
+ match self.reset_resolver().await {
+ Ok(_) => {
+ let _ = tx.send(Ok(()));
+ }
+ Err(err) => {
+ let _ = tx.send(Err(err));
+ }
+ }
+ }
+ }
+ }
+
+ std::mem::drop(self);
+ }
+
+ /// Spawns a new [trust_dns_server::server::ServerFuture], used whenever moving away from the
+ /// [ResolverState::Shutdown] state.
+ async fn spawn_new_server(&mut self) -> Result<(), Error> {
+ self.stop_server().await;
+ if let Some(tx) = self.command_sender.upgrade() {
+ let resolver_handle = ResolverImpl { tx };
+ let mut server = ServerFuture::new(resolver_handle);
+ let listening_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), self.port);
+ let udp_sock = tokio::net::UdpSocket::bind(listening_addr)
+ .await
+ .map_err(Error::UdpBindError)?;
+ let tcp_sock = tokio::net::TcpListener::bind(listening_addr)
+ .await
+ .map_err(Error::TcpBindError)?;
+ server.register_socket(udp_sock);
+ server.register_listener(tcp_sock, std::time::Duration::from_secs(1));
+
+ let (server_done_tx, server_done_rx) = oneshot::channel();
+ let server_handle = tokio::spawn(async move {
+ if let Err(err) = server.block_until_done().await {
+ log::error!("DNS server stopped: {}", err);
+ }
+ let _ = server_done_tx.send(());
+ });
+
+ self.dns_server = Some((server_handle, server_done_rx));
+ }
+ Ok(())
+ }
+
+ /// Tries to stop the server future as best as it can.
+ async fn stop_server(&mut self) {
+ if let Some((old_server, done_rx)) = self.dns_server.take() {
+ old_server.abort();
+ if done_rx.await.is_err() {
+ log::error!("Server future was already stopped");
+ }
+ }
+ }
+
+ /// Resets the current upstream resolver to clear it's config.
+ async fn reset_resolver(&mut self) -> Result<(), Error> {
+ log::trace!("Resetting filtering resolver");
+ let (best_interface, resolver_addresses) = self.get_resolver_config();
+ self.runtime_provider.update_best_interface(best_interface);
+ let resolver_config = ResolverConfig::from_parts(
+ None,
+ vec![],
+ NameServerConfigGroup::from_ips_clear(resolver_addresses, 53, false),
+ );
+ let mut resolver_options = ResolverOpts::default();
+ resolver_options.preserve_intermediates = true;
+ let resolver = AsyncResolver::new(
+ resolver_config.clone(),
+ resolver_options,
+ self.runtime_provider.clone(),
+ )
+ .map_err(Error::LaunchResolver)?;
+ self.excluded_resolver = resolver;
+ Ok(())
+ }
+
+ /// Gets the best interface to use and a list of upstream resolver addresses to use when
+ /// resolving domains. Returns an empty config if the current resolver state isn't
+ /// [ResolverState::Active].
+ fn get_resolver_config(&self) -> (&str, &[IpAddr]) {
+ match &self.resolver_state {
+ ResolverState::Active(ref resolvers) => resolvers
+ .as_ref()
+ .filter(|(_, addresses)| !addresses.iter().any(|ip| ip.is_loopback()))
+ .map(|(interface_name, addresses)| (interface_name.as_str(), addresses.as_slice()))
+ .unwrap_or(("", &[])),
+ _ => ("", &[]),
+ }
+ }
+
+ /// Constructs a lookup future for a given DNS query.
+ fn resolve(
+ &mut self,
+ query: LowerQuery,
+ tx: oneshot::Sender<Box<dyn LookupObject>>,
+ ) -> impl Future<Output = ()> {
+ let empty_response = Box::new(EmptyLookup) as Box<dyn LookupObject>;
+ if !self.should_service_request(&query) {
+ let _ = tx.send(empty_response);
+ return Either::Left(async {});
+ }
+
+ log::trace!("Looking up {}", query.name());
+
+ let unblock_tx = self.tunnel_tx.clone();
+ let lookup: Box<dyn Future<Output = Result<Lookup, ResolveError>> + Unpin + Send> =
+ Box::new(self.excluded_resolver.lookup(
+ query.name().clone(),
+ query.query_type(),
+ Default::default(),
+ ));
+ let resolver_state = self.resolver_state.clone();
+ Either::Right(async move {
+ match lookup.await {
+ Ok(result) => {
+ let lookup = ForwardLookup(result);
+ let ip_records = lookup
+ .iter()
+ .filter_map(|record| match record.rdata() {
+ RData::A(ipv4) => Some(IpAddr::from(*ipv4)),
+ RData::AAAA(ipv6) => Some(IpAddr::from(*ipv6)),
+ _ => None,
+ })
+ .collect::<BTreeSet<_>>();
+
+ if !ip_records.is_empty() {
+ if resolver_state.is_running() {
+ Self::unblock_ips(unblock_tx, ip_records).await;
+ }
+ }
+ if tx.send(Box::new(lookup)).is_err() {
+ log::error!("Failed to send response to resolver");
+ }
+ }
+ Err(err) => {
+ log::trace!("Failed to resolve {}: {}", query, err);
+ let _ = tx.send(empty_response);
+ }
+ }
+ })
+ }
+
+ /// Unblocks a set of addresses in the firewall by sending a message to the tunnel state
+ /// machine and waiting for completion. Be careful not to call this from the context of
+ /// [FilteringResolver::run] and instead call it in a different task, as otherwise a deadlock
+ /// will occur.
+ async fn unblock_ips(maybe_tx: TunnelCommandSender, addresses: BTreeSet<IpAddr>) {
+ let (done_tx, done_rx) = oneshot::channel();
+ if maybe_tx
+ .upgrade()
+ .and_then(|tx| {
+ tx.unbounded_send(TunnelCommand::AddAllowedIps(addresses, done_tx))
+ .ok()
+ })
+ .is_some()
+ {
+ let _ = done_rx.await;
+ } else {
+ log::error!("Failed to send IPs to unblocker");
+ }
+ }
+
+ /// Determines whether a query should be responded to given the current state of the resolver
+ /// and if the query is valid.
+ fn should_service_request(&self, query: &LowerQuery) -> bool {
+ self.resolver_state.is_running() && self.allow_query(query)
+ }
+
+ /// Determines whether a DNS query is allowable. Currently, this implies that the query is
+ /// either a `A`, `AAAA` or a `CNAME` query for `captive.apple.com`.
+ fn allow_query(&self, query: &LowerQuery) -> bool {
+ let captive_apple_com: LowerName =
+ LowerName::from(Name::from_str(CAPTIVE_PORTAL_DOMAIN).unwrap());
+ ALLOWED_RECORD_TYPES.contains(&query.query_type()) && query.name() == &captive_apple_com
+ }
+}
+
+/// An implementation of [trust_dns_server::server::RequestHandler] that forwards queries to
+/// `FilteringResolver` as `ResolverMessage::Request` messages.
+struct ResolverImpl {
+ tx: Arc<mpsc::Sender<ResolverMessage>>,
+}
+
+impl ResolverImpl {
+ fn build_response<'a>(
+ message: &'a MessageRequest,
+ lookup: &'a mut Box<dyn LookupObject>,
+ ) -> MessageResponse<'a, 'a> {
+ let mut response_header = Header::new();
+ response_header.set_id(message.id());
+ response_header.set_op_code(OpCode::Query);
+ response_header.set_message_type(MessageType::Response);
+ response_header.set_authoritative(false);
+
+ MessageResponseBuilder::from_message_request(message).build(
+ response_header,
+ lookup.iter(),
+ // forwarder responses only contain query answers, no ns,soa or additionals
+ Box::new(std::iter::empty()) as Box<dyn Iterator<Item = _> + Send>,
+ Box::new(std::iter::empty()) as Box<dyn Iterator<Item = _> + Send>,
+ Box::new(std::iter::empty()) as Box<dyn Iterator<Item = _> + Send>,
+ )
+ }
+
+ async fn lookup<R: ResponseHandler>(&self, message: &Request, mut response_handler: R) {
+ let tx_ref: &mpsc::Sender<ResolverMessage> = &*self.tx;
+ let mut tx = tx_ref.clone();
+
+ let query = message.query();
+ let (lookup_tx, lookup_rx) = oneshot::channel();
+ let _ = tx
+ .send(ResolverMessage::Request(query.clone(), lookup_tx))
+ .await;
+ let mut lookup_result: Box<dyn LookupObject> = lookup_rx
+ .await
+ .unwrap_or_else(|_| Box::new(EmptyLookup) as Box<dyn LookupObject>);
+ let response = Self::build_response(&message, &mut lookup_result);
+
+ if let Err(err) = response_handler.send_response(response).await {
+ log::error!("Failed to send response: {}", err);
+ }
+ }
+}
+
+#[async_trait::async_trait]
+impl RequestHandler for ResolverImpl {
+ async fn handle_request<R: ResponseHandler>(
+ &self,
+ request: &Request,
+ response_handle: R,
+ ) -> ResponseInfo {
+ if !request.src().ip().is_loopback() {
+ log::error!("Dropping a stray request from outside: {}", request.src());
+ return Header::new().into();
+ }
+ if let MessageType::Query = request.message_type() {
+ match request.op_code() {
+ OpCode::Query => {
+ self.lookup(request, response_handle).await;
+ }
+ _ => {
+ log::trace!("Dropping non-query request: {:?}", request);
+ }
+ };
+ }
+
+ return Header::new().into();
+ }
+}
+
+/// RuntimeProvider is used to construct sockets to reach the upstream resolver.
+#[derive(Clone)]
+struct RuntimeProvider {
+ best_interface: Arc<Mutex<Option<NonZeroU32>>>,
+}
+
+impl RuntimeProvider {
+ fn new() -> Self {
+ Self {
+ best_interface: Arc::new(Mutex::new(None)),
+ }
+ }
+
+ fn update_best_interface(&self, best_interface: &str) {
+ let ifname = match CString::new(best_interface) {
+ Ok(name) => name,
+ Err(err) => {
+ log::error!("Failed to construct an interface name CString: {}", err);
+ return;
+ }
+ };
+ if let Some(index) = NonZeroU32::new(unsafe { libc::if_nametoindex(ifname.as_ptr()) }) {
+ *self.best_interface.lock().unwrap() = Some(index);
+ }
+ }
+}
+
+impl proto::runtime_provider::RuntimeProvider for RuntimeProvider {
+ type UdpSocket = tokio::net::UdpSocket;
+ type TcpConnection = AsyncIoTokioAsStd<tokio::net::TcpStream>;
+ type Time = TokioTime;
+
+ fn connect_tcp(
+ &self,
+ addr: SocketAddr,
+ ) -> Pin<Box<dyn Future<Output = io::Result<Self::TcpConnection>> + Send>> {
+ let best_interface = self.best_interface.clone();
+
+ Box::pin(async move {
+ let raw_fd = open_socket(addr, Type::STREAM, socket2::Protocol::TCP, best_interface)?;
+
+ let socket = unsafe { tokio::net::TcpSocket::from_raw_fd(raw_fd) };
+ socket.connect(addr).await.map(AsyncIoTokioAsStd)
+ })
+ }
+
+ fn bind_udp(
+ &self,
+ addr: SocketAddr,
+ ) -> Pin<Box<dyn Future<Output = io::Result<Self::UdpSocket>> + Send>> {
+ let best_interface = self.best_interface.clone();
+ Box::pin(async move {
+ let raw_fd = open_socket(
+ addr,
+ socket2::Type::DGRAM,
+ socket2::Protocol::UDP,
+ best_interface.clone(),
+ )?;
+
+ let std_socket = unsafe { net::UdpSocket::from_raw_fd(raw_fd) };
+ tokio::net::UdpSocket::from_std(std_socket)
+ })
+ }
+
+ fn spawn_bg<F>(&self, f: F)
+ where
+ F: Future<Output = Result<(), trust_dns_server::proto::error::ProtoError>> + Send + 'static,
+ {
+ tokio::spawn(f);
+ }
+}
+
+fn open_socket(
+ addr: SocketAddr,
+ sock_type: Type,
+ protocol: socket2::Protocol,
+ best_interface: Arc<Mutex<Option<NonZeroU32>>>,
+) -> io::Result<RawFd> {
+ let socket = Socket::new(Domain::for_address(addr), sock_type, Some(protocol))?;
+
+ socket.set_nonblocking(true)?;
+
+ match best_interface
+ .lock()
+ .expect("best interface lock poisoned")
+ .as_ref()
+ {
+ Some(iface_index) => {
+ if let Err(err) = socket.bind_device_by_index(Some(*iface_index)) {
+ log::error!("Failed to bind by index: {}", err);
+ return Err(err);
+ }
+ }
+ None => {
+ log::error!("Failed to get best interface index");
+ }
+ };
+ Ok(socket.into_raw_fd())
+}
+
+struct ForwardLookup(Lookup);
+
+/// This trait has to be reimplemented for the Lookup so that it can be sent back to the
+/// RequestHandler implementation.
+impl LookupObject for ForwardLookup {
+ fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ fn iter<'a>(&'a self) -> Box<dyn Iterator<Item = &'a Record> + Send + 'a> {
+ Box::new(self.0.record_iter())
+ }
+
+ fn take_additionals(&mut self) -> Option<Box<dyn LookupObject>> {
+ None
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use std::{fs, net::UdpSocket, process::Command};
+ use subslice::SubsliceExt;
+
+ fn random_port() -> u16 {
+ let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
+ socket.local_addr().unwrap().port()
+ }
+
+ const NAMESERVER: &[u8] = b"nameserver";
+
+ fn read_resolvconf() -> Option<(String, Vec<IpAddr>)> {
+ let contents = fs::read("/etc/resolv.conf").unwrap();
+ let nameserver_index = contents
+ .find(NAMESERVER)
+ .expect("Failed to read /etc/resolv.conf");
+ let end = contents[nameserver_index..]
+ .find(b"\n")
+ .expect("no \n after nameserver")
+ + nameserver_index;
+ let ip_addr_subslice = &contents[nameserver_index + NAMESERVER.len()..end];
+
+ let resolver_ip =
+ IpAddr::from_str(std::str::from_utf8(ip_addr_subslice).unwrap().trim()).unwrap();
+ let route_output = String::from_utf8(
+ Command::new("route")
+ .arg("get")
+ .arg(resolver_ip.to_string())
+ .output()
+ .expect("Failed to run 'route get'")
+ .stdout,
+ )
+ .unwrap();
+
+ let mut output_parts = route_output.split_whitespace();
+ while let Some(part) = output_parts.next() {
+ if part.trim() == "interface:" {
+ return Some((output_parts.next().unwrap().to_string(), vec![resolver_ip]));
+ }
+ }
+ panic!("Couldn't deduce interface")
+ }
+
+ async fn start_resolver() -> (
+ ResolverHandle,
+ u16,
+ mpsc::UnboundedReceiver<TunnelCommand>,
+ Arc<mpsc::UnboundedSender<TunnelCommand>>,
+ ) {
+ let (tx, rx) = futures::channel::mpsc::unbounded();
+ let tx = Arc::new(tx);
+ let port = random_port();
+
+ let resolver_handle = super::start_resolver_inner(Arc::downgrade(&tx), port)
+ .await
+ .unwrap();
+ (resolver_handle, port, rx, tx)
+ }
+
+ async fn get_test_resolver(port: u16) -> trust_dns_server::resolver::TokioAsyncResolver {
+ let resolver_config = ResolverConfig::from_parts(
+ None,
+ vec![],
+ NameServerConfigGroup::from_ips_clear(&[Ipv4Addr::LOCALHOST.into()], port, true),
+ );
+ AsyncResolver::new(
+ resolver_config,
+ ResolverOpts::default(),
+ proto::TokioRuntime,
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn test_successful_lookup() {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let (handle, port, mut cmd_rx, _txx) = rt.block_on(start_resolver());
+ let test_resolver = rt.block_on(get_test_resolver(port));
+ let resolver_config = read_resolvconf();
+ rt.block_on(async { handle.set_active(resolver_config).await })
+ .expect("failed to make resovler active");
+
+ let captive_portal_domain = LowerName::from(Name::from_str(CAPTIVE_PORTAL_DOMAIN).unwrap());
+ let resolver_result = rt.block_on(async move {
+ let dns_request =
+ test_resolver.lookup(captive_portal_domain, RecordType::A, Default::default());
+ let unblock_request = cmd_rx.next();
+
+ use futures::future::Either;
+ match futures::future::select(dns_request, unblock_request).await {
+ Either::Left((_resolution_result, _unblock_request_future)) => {
+ panic!("DNS response recieved before unblocking request")
+ }
+ Either::Right((unblock_request, resolution)) => {
+ std::mem::drop(unblock_request);
+ resolution.await
+ }
+ }
+ });
+ resolver_result.expect("Failed to resolve test domain");
+ }
+
+ #[test]
+ fn test_failed_lookup_when_active() {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+
+ let (handle, port, mut cmd_rx, _tx) = rt.block_on(start_resolver());
+ let test_resolver = rt.block_on(get_test_resolver(port));
+
+ let resolver_config = read_resolvconf();
+ rt.block_on(async { handle.set_active(resolver_config).await })
+ .expect("failed to make resovler active");
+
+ let captive_portal_domain = LowerName::from(Name::from_str("apple.com").unwrap());
+ let resolver_result = rt.block_on(async move {
+ let dns_request =
+ test_resolver.lookup(captive_portal_domain, RecordType::A, Default::default());
+ let unblock_request = cmd_rx.next();
+
+ use futures::future::Either;
+ match futures::future::select(dns_request, unblock_request).await {
+ Either::Left((dns_response, _unblock_request_future)) => dns_response,
+ Either::Right((_unblock_request, _resolution)) => {
+ panic!(
+ "There should be no unblocking for a request that shouldn't be serviced"
+ );
+ }
+ }
+ });
+ assert!(
+ resolver_result.is_err(),
+ "Non-whitelisted DNS request should fail"
+ )
+ }
+
+ #[test]
+ fn test_failed_lookup_when_inactive() {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+
+ let (handle, port, mut cmd_rx, _tx) = rt.block_on(start_resolver());
+ let test_resolver = rt.block_on(get_test_resolver(port));
+
+ rt.block_on(async { handle.set_inactive().await })
+ .expect("failed to make resovler active");
+
+ let captive_portal_domain = LowerName::from(Name::from_str("apple.com").unwrap());
+ let resolver_result = rt.block_on(async move {
+ let dns_request =
+ test_resolver.lookup(captive_portal_domain, RecordType::A, Default::default());
+ let unblock_request = cmd_rx.next();
+
+ use futures::future::Either;
+ match futures::future::select(dns_request, unblock_request).await {
+ Either::Left((dns_response, _unblock_request_future)) => {
+ dns_response
+ }
+ Either::Right((_unblock_request, _resolution)) => {
+ panic!("There should be no unblocking for for a request when the resolver is inactive");
+ }
+ }
+
+ });
+ assert!(
+ resolver_result.is_err(),
+ "Non-whitelisted DNS request should fail"
+ )
+ }
+
+ #[test]
+ fn test_unbinding() {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+
+ let (handle, port, mut _cmd_rx, _tx) = rt.block_on(start_resolver());
+ let server_sockaddr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), port);
+
+ let _ = UdpSocket::bind(server_sockaddr)
+ .expect("Failed to bind to resolver socket addr when it should be unbound");
+
+ rt.block_on(async { handle.set_inactive().await })
+ .expect("failed to make resovler active");
+
+ assert!(UdpSocket::bind(server_sockaddr).is_err());
+
+ rt.block_on(async { handle.shutdown().await })
+ .expect("failed to make resovler active");
+
+ // macOS takes it sweet time reaping the socket
+ std::thread::sleep(std::time::Duration::from_millis(300));
+ UdpSocket::bind(server_sockaddr)
+ .expect("Failed to bind to resolver socket addr when it should be unbound");
+ }
+}
diff --git a/talpid-core/src/tunnel_state_machine/connected_state.rs b/talpid-core/src/tunnel_state_machine/connected_state.rs
index 546f9e92ab..e8777b5d14 100644
--- a/talpid-core/src/tunnel_state_machine/connected_state.rs
+++ b/talpid-core/src/tunnel_state_machine/connected_state.rs
@@ -185,6 +185,18 @@ impl ConnectedState {
use self::EventConsequence::*;
match command {
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(_allowed_ips, done_tx)) => {
+ let _ = done_tx.send(());
+ SameState(self.into())
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ let _ = done_tx.send(shared_values.deactivate_filtering_resolver(enable));
+ SameState(self.into())
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(_new_config)) => SameState(self.into()),
Some(TunnelCommand::AllowLan(allow_lan)) => {
if let Err(error_cause) = shared_values.set_allow_lan(allow_lan) {
self.disconnect(shared_values, AfterDisconnect::Block(error_cause))
diff --git a/talpid-core/src/tunnel_state_machine/connecting_state.rs b/talpid-core/src/tunnel_state_machine/connecting_state.rs
index 3e16c7a23d..28a80260dc 100644
--- a/talpid-core/src/tunnel_state_machine/connecting_state.rs
+++ b/talpid-core/src/tunnel_state_machine/connecting_state.rs
@@ -238,6 +238,28 @@ impl ConnectingState {
))
}
+ fn reset_firewall(self, shared_values: &mut SharedTunnelStateValues) -> EventConsequence {
+ match Self::set_firewall_policy(
+ shared_values,
+ &self.tunnel_parameters,
+ &self.tunnel_metadata,
+ ) {
+ Ok(()) => {
+ cfg_if! {
+ if #[cfg(target_os = "android")] {
+ self.disconnect(shared_values, AfterDisconnect::Reconnect(0))
+ } else {
+ EventConsequence::SameState(self.into())
+ }
+ }
+ }
+ Err(error) => self.disconnect(
+ shared_values,
+ AfterDisconnect::Block(ErrorStateCause::SetFirewallPolicyError(error)),
+ ),
+ }
+ }
+
fn handle_commands(
self,
command: Option<TunnelCommand>,
@@ -246,29 +268,24 @@ impl ConnectingState {
use self::EventConsequence::*;
match command {
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(_, done_tx)) => {
+ let _ = done_tx.send(());
+ SameState(self.into())
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ let _ = done_tx.send(shared_values.deactivate_filtering_resolver(enable));
+ SameState(self.into())
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(_new_config)) => SameState(self.into()),
Some(TunnelCommand::AllowLan(allow_lan)) => {
if let Err(error_cause) = shared_values.set_allow_lan(allow_lan) {
self.disconnect(shared_values, AfterDisconnect::Block(error_cause))
} else {
- match Self::set_firewall_policy(
- shared_values,
- &self.tunnel_parameters,
- &self.tunnel_metadata,
- ) {
- Ok(()) => {
- cfg_if! {
- if #[cfg(target_os = "android")] {
- self.disconnect(shared_values, AfterDisconnect::Reconnect(0))
- } else {
- SameState(self.into())
- }
- }
- }
- Err(error) => self.disconnect(
- shared_values,
- AfterDisconnect::Block(ErrorStateCause::SetFirewallPolicyError(error)),
- ),
- }
+ let next_state = self.reset_firewall(shared_values);
+ return next_state;
}
}
Some(TunnelCommand::AllowEndpoint(endpoint, tx)) => {
@@ -283,6 +300,9 @@ impl ConnectingState {
AfterDisconnect::Block(ErrorStateCause::SetFirewallPolicyError(error)),
);
}
+ let next_state = self.reset_firewall(shared_values);
+ let _ = tx.send(());
+ return next_state;
}
if let Err(_) = tx.send(()) {
log::error!("The AllowEndpoint receiver was dropped");
@@ -473,6 +493,13 @@ impl TunnelState for ConnectingState {
if shared_values.is_offline {
return ErrorState::enter(shared_values, ErrorStateCause::IsOffline);
}
+ #[cfg(target_os = "macos")]
+ if let Err(err) = shared_values.disable_filtering_resolver() {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to disable custom resolver")
+ );
+ }
match shared_values
.tunnel_parameters_generator
.generate(retry_attempt)
diff --git a/talpid-core/src/tunnel_state_machine/disconnected_state.rs b/talpid-core/src/tunnel_state_machine/disconnected_state.rs
index c644dc4c31..58e5aa23ac 100644
--- a/talpid-core/src/tunnel_state_machine/disconnected_state.rs
+++ b/talpid-core/src/tunnel_state_machine/disconnected_state.rs
@@ -3,14 +3,27 @@ use super::{
TunnelCommandReceiver, TunnelState, TunnelStateTransition, TunnelStateWrapper,
};
use crate::firewall::FirewallPolicy;
+#[cfg(target_os = "macos")]
+use crate::{dns, resolver};
use futures::StreamExt;
+#[cfg(target_os = "macos")]
+use std::{
+ collections::BTreeSet,
+ net::{IpAddr, Ipv4Addr},
+};
+#[cfg(target_os = "macos")]
+use talpid_types::tunnel::ErrorStateCause;
use talpid_types::ErrorExt;
/// No tunnel is running.
-pub struct DisconnectedState;
+pub struct DisconnectedState {
+ #[cfg(target_os = "macos")]
+ allowed_ips: BTreeSet<IpAddr>,
+}
impl DisconnectedState {
fn set_firewall_policy(
+ &mut self,
shared_values: &mut SharedTunnelStateValues,
should_reset_firewall: bool,
) {
@@ -18,12 +31,19 @@ impl DisconnectedState {
let policy = FirewallPolicy::Blocked {
allow_lan: shared_values.allow_lan,
allowed_endpoint: shared_values.allowed_endpoint.clone(),
+ #[cfg(target_os = "macos")]
+ allowed_ips: self.allowed_ips.clone(),
+ #[cfg(target_os = "macos")]
+ allow_gid_exclusion_traffic: shared_values.enable_filtering_resolver,
};
- shared_values.firewall.apply_policy(policy).map_err(|e| {
+
+ let firewall_result = shared_values.firewall.apply_policy(policy).map_err(|e| {
e.display_chain_with_msg(
"Failed to apply blocking firewall policy for disconnected state",
)
- })
+ });
+
+ firewall_result
} else if should_reset_firewall {
shared_values
.firewall
@@ -61,6 +81,35 @@ impl DisconnectedState {
}
}
}
+
+ fn reset_dns(shared_values: &mut SharedTunnelStateValues) {
+ if let Err(error) = shared_values.dns_monitor.reset() {
+ log::error!("{}", error.display_chain_with_msg("Unable to reset DNS"));
+ }
+ }
+
+ /// Starts the filtering resolver and configures host to use it.
+ #[cfg(target_os = "macos")]
+ fn start_filtering_resolver(
+ &mut self,
+ shared_values: &mut SharedTunnelStateValues,
+ ) -> Result<(), either::Either<resolver::Error, dns::Error>> {
+ use either::Either;
+ let system_config = shared_values
+ .dns_monitor
+ .get_system_config()
+ .map_err(Either::Right)?;
+
+ shared_values
+ .runtime
+ .block_on(shared_values.filtering_resolver.set_active(system_config))
+ .map_err(Either::Left)?;
+ shared_values
+ .dns_monitor
+ .set("lo", &[Ipv4Addr::LOCALHOST.into()])
+ .map_err(resolver::Error::SystemDnsError)
+ .map_err(Either::Left)
+ }
}
impl TunnelState for DisconnectedState {
@@ -70,22 +119,44 @@ impl TunnelState for DisconnectedState {
shared_values: &mut SharedTunnelStateValues,
should_reset_firewall: Self::Bootstrap,
) -> (TunnelStateWrapper, TunnelStateTransition) {
+ let mut disconnected_state = DisconnectedState {
+ #[cfg(target_os = "macos")]
+ allowed_ips: BTreeSet::new(),
+ };
+
+ #[cfg(target_os = "macos")]
+ if shared_values.enable_filtering_resolver && shared_values.block_when_disconnected {
+ if let Err(err) = disconnected_state.start_filtering_resolver(shared_values) {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to start filtering resolver:")
+ );
+ }
+ } else {
+ if let Err(error) = shared_values.disable_filtering_resolver() {
+ log::error!(
+ "{}",
+ error.display_chain_with_msg("Unable to disable filtering resolver")
+ );
+ }
+ }
+
#[cfg(windows)]
Self::register_split_tunnel_addresses(shared_values, should_reset_firewall);
- Self::set_firewall_policy(shared_values, should_reset_firewall);
+ disconnected_state.set_firewall_policy(shared_values, should_reset_firewall);
#[cfg(target_os = "linux")]
shared_values.reset_connectivity_check();
#[cfg(target_os = "android")]
shared_values.tun_provider.close_tun();
(
- TunnelStateWrapper::from(DisconnectedState),
+ TunnelStateWrapper::from(disconnected_state),
TunnelStateTransition::Disconnected,
)
}
fn handle_event(
- self,
+ mut self,
runtime: &tokio::runtime::Handle,
commands: &mut TunnelCommandReceiver,
shared_values: &mut SharedTunnelStateValues,
@@ -101,13 +172,13 @@ impl TunnelState for DisconnectedState {
.set_allow_lan(allow_lan)
.expect("Failed to set allow LAN parameter");
- Self::set_firewall_policy(shared_values, true);
+ self.set_firewall_policy(shared_values, true);
}
SameState(self.into())
}
Some(TunnelCommand::AllowEndpoint(endpoint, tx)) => {
if shared_values.set_allowed_endpoint(endpoint) {
- Self::set_firewall_policy(shared_values, true);
+ self.set_firewall_policy(shared_values, true);
}
if let Err(_) = tx.send(()) {
log::error!("The AllowEndpoint receiver was dropped");
@@ -125,9 +196,18 @@ impl TunnelState for DisconnectedState {
Some(TunnelCommand::BlockWhenDisconnected(block_when_disconnected)) => {
if shared_values.block_when_disconnected != block_when_disconnected {
shared_values.block_when_disconnected = block_when_disconnected;
+ self.set_firewall_policy(shared_values, true);
#[cfg(windows)]
Self::register_split_tunnel_addresses(shared_values, true);
- Self::set_firewall_policy(shared_values, true);
+ #[cfg(target_os = "macos")]
+ if block_when_disconnected && shared_values.enable_filtering_resolver {
+ if let Err(err) = self.start_filtering_resolver(shared_values) {
+ let block_reason = map_filtering_resolver_start(&err);
+ return NewState(ErrorState::enter(shared_values, block_reason));
+ }
+ } else {
+ Self::reset_dns(shared_values);
+ }
}
SameState(self.into())
}
@@ -137,6 +217,7 @@ impl TunnelState for DisconnectedState {
}
Some(TunnelCommand::Connect) => NewState(ConnectingState::enter(shared_values, 0)),
Some(TunnelCommand::Block(reason)) => {
+ Self::reset_dns(shared_values);
NewState(ErrorState::enter(shared_values, reason))
}
#[cfg(target_os = "android")]
@@ -149,8 +230,91 @@ impl TunnelState for DisconnectedState {
shared_values.split_tunnel.set_paths(&paths, result_tx);
SameState(self.into())
}
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ if !enable {
+ if let Err(err) = shared_values.dns_monitor.reset() {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to reset DNS config")
+ );
+ }
+ if let Err(err) = shared_values.deactivate_filtering_resolver(enable) {
+ let _ = done_tx.send(Err(err));
+ if shared_values.enable_filtering_resolver {
+ self.set_firewall_policy(shared_values, false);
+ }
+ return SameState(self.into());
+ };
+ }
+ shared_values.enable_filtering_resolver = enable;
+ self.set_firewall_policy(shared_values, false);
+ if shared_values.block_when_disconnected && enable {
+ if let Err(err) = self.start_filtering_resolver(shared_values) {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to start filtering resolver:")
+ );
+
+ let error_cause = map_filtering_resolver_start(&err);
+ let _ = done_tx.send(Err(err.left_or_else(resolver::Error::from)));
+ return NewState(ErrorState::enter(shared_values, error_cause));
+ }
+ }
+ let _ = done_tx.send(Ok(()));
+ SameState(self.into())
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(host_config)) => {
+ if shared_values.block_when_disconnected && shared_values.enable_filtering_resolver
+ {
+ if let Err(err) = shared_values
+ .runtime
+ .block_on(shared_values.filtering_resolver.set_active(host_config))
+ {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to activate filtering resolver")
+ );
+ return NewState(ErrorState::enter(
+ shared_values,
+ ErrorStateCause::FilteringResolverError,
+ ));
+ }
+ }
+ SameState(self.into())
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(allowed_ips, done_tx)) => {
+ let new_addresses = allowed_ips.iter().any(|ip| self.allowed_ips.insert(*ip));
+ if new_addresses {
+ let _ = self.set_firewall_policy(shared_values, false);
+ }
+ let _ = done_tx.send(());
+
+ SameState(self.into())
+ }
+
+ None => {
+ Self::reset_dns(shared_values);
+ Finished
+ }
Some(_) => SameState(self.into()),
- None => Finished,
}
}
}
+
+/// Maps a DNS or a resovler error to an [ErrorStateCause] to be used when failing to start a
+/// filtering resolver.
+#[cfg(target_os = "macos")]
+fn map_filtering_resolver_start(
+ err: &either::Either<resolver::Error, dns::Error>,
+) -> ErrorStateCause {
+ match err {
+ either::Either::Right(_dns_err) => ErrorStateCause::SetDnsError,
+ either::Either::Left(resolver::Error::SystemDnsError(_)) => {
+ ErrorStateCause::ReadSystemDnsConfig
+ }
+ either::Either::Left(_other_err) => ErrorStateCause::FilteringResolverError,
+ }
+}
diff --git a/talpid-core/src/tunnel_state_machine/disconnecting_state.rs b/talpid-core/src/tunnel_state_machine/disconnecting_state.rs
index 8f6f6ae68b..e1fab84ffc 100644
--- a/talpid-core/src/tunnel_state_machine/disconnecting_state.rs
+++ b/talpid-core/src/tunnel_state_machine/disconnecting_state.rs
@@ -28,6 +28,18 @@ impl DisconnectingState {
self.after_disconnect = match after_disconnect {
AfterDisconnect::Nothing => match command {
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(_, done_tx)) => {
+ let _ = done_tx.send(());
+ AfterDisconnect::Nothing
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ let _ = done_tx.send(shared_values.deactivate_filtering_resolver(enable));
+ AfterDisconnect::Nothing
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(_new_config)) => AfterDisconnect::Nothing,
Some(TunnelCommand::AllowLan(allow_lan)) => {
let _ = shared_values.set_allow_lan(allow_lan);
AfterDisconnect::Nothing
@@ -66,6 +78,20 @@ impl DisconnectingState {
}
},
AfterDisconnect::Block(reason) => match command {
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(_, done_tx)) => {
+ let _ = done_tx.send(());
+ AfterDisconnect::Block(reason)
+ }
+
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ let _ = done_tx.send(shared_values.deactivate_filtering_resolver(enable));
+ AfterDisconnect::Block(reason)
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(_new_config)) => AfterDisconnect::Block(reason),
+
Some(TunnelCommand::AllowLan(allow_lan)) => {
let _ = shared_values.set_allow_lan(allow_lan);
AfterDisconnect::Block(reason)
@@ -113,6 +139,21 @@ impl DisconnectingState {
let _ = shared_values.set_allow_lan(allow_lan);
AfterDisconnect::Reconnect(retry_attempt)
}
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ let _ = done_tx.send(shared_values.deactivate_filtering_resolver(enable));
+ AfterDisconnect::Reconnect(retry_attempt)
+ }
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(_new_config)) => {
+ AfterDisconnect::Reconnect(retry_attempt)
+ }
+
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(_allowed_ips, done_tx)) => {
+ let _ = done_tx.send(());
+ AfterDisconnect::Reconnect(retry_attempt)
+ }
Some(TunnelCommand::AllowEndpoint(endpoint, tx)) => {
let _ = shared_values.set_allowed_endpoint(endpoint);
if let Err(_) = tx.send(()) {
diff --git a/talpid-core/src/tunnel_state_machine/error_state.rs b/talpid-core/src/tunnel_state_machine/error_state.rs
index 7b9be4c479..6a32ec157c 100644
--- a/talpid-core/src/tunnel_state_machine/error_state.rs
+++ b/talpid-core/src/tunnel_state_machine/error_state.rs
@@ -3,7 +3,14 @@ use super::{
TunnelCommandReceiver, TunnelState, TunnelStateTransition, TunnelStateWrapper,
};
use crate::firewall::FirewallPolicy;
+#[cfg(target_os = "macos")]
+use crate::resolver;
use futures::StreamExt;
+#[cfg(target_os = "macos")]
+use std::{
+ collections::BTreeSet,
+ net::{IpAddr, Ipv4Addr},
+};
use talpid_types::{
tunnel::{self as talpid_tunnel, ErrorStateCause, FirewallPolicyError},
ErrorExt,
@@ -11,17 +18,37 @@ use talpid_types::{
/// No tunnel is running and all network connections are blocked.
pub struct ErrorState {
+ #[cfg(target_os = "macos")]
+ allowed_ips: BTreeSet<IpAddr>,
block_reason: ErrorStateCause,
}
impl ErrorState {
- /// Returns true if firewall policy was applied successfully
+ fn set_firewall(
+ &self,
+ shared_values: &mut SharedTunnelStateValues,
+ ) -> Result<(), FirewallPolicyError> {
+ Self::set_firewall_policy(
+ shared_values,
+ #[cfg(target_os = "macos")]
+ self.allowed_ips.clone(),
+ #[cfg(target_os = "macos")]
+ shared_values.enable_filtering_resolver,
+ )
+ }
+
fn set_firewall_policy(
shared_values: &mut SharedTunnelStateValues,
+ #[cfg(target_os = "macos")] allowed_ips: BTreeSet<IpAddr>,
+ #[cfg(target_os = "macos")] allow_gid_exclusion_traffic: bool,
) -> Result<(), FirewallPolicyError> {
let policy = FirewallPolicy::Blocked {
allow_lan: shared_values.allow_lan,
allowed_endpoint: shared_values.allowed_endpoint.clone(),
+ #[cfg(target_os = "macos")]
+ allowed_ips,
+ #[cfg(target_os = "macos")]
+ allow_gid_exclusion_traffic,
};
#[cfg(target_os = "linux")]
@@ -61,6 +88,12 @@ impl ErrorState {
}
}
}
+
+ fn reset_dns(shared_values: &mut SharedTunnelStateValues) {
+ if let Err(error) = shared_values.dns_monitor.reset() {
+ log::error!("{}", error.display_chain_with_msg("Unable to reset DNS"));
+ }
+ }
}
impl TunnelState for ErrorState {
@@ -80,8 +113,69 @@ impl TunnelState for ErrorState {
);
}
+ #[cfg(target_os = "macos")]
+ let host_config = if shared_values.enable_filtering_resolver
+ && !block_reason.prevents_filtering_resolver()
+ {
+ if let Err(err) = shared_values
+ .dns_monitor
+ .set("lo", &[Ipv4Addr::LOCALHOST.into()])
+ {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg(
+ "Failed to configure system to use filtering resolver"
+ )
+ );
+ return Self::enter(shared_values, ErrorStateCause::SetDnsError);
+ }
+ match shared_values.dns_monitor.get_system_config() {
+ Ok(host_config) => host_config,
+ Err(err) => {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to start filtering resolver")
+ );
+ if let Err(err) = shared_values.dns_monitor.reset() {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg(
+ "Faield to reset DNS after failing to obtain host config"
+ )
+ );
+ }
+ return Self::enter(shared_values, ErrorStateCause::FilteringResolverError);
+ }
+ }
+ } else {
+ None
+ };
+
#[cfg(not(target_os = "android"))]
- let block_failure = Self::set_firewall_policy(shared_values).err();
+ let block_failure = Self::set_firewall_policy(
+ shared_values,
+ #[cfg(target_os = "macos")]
+ BTreeSet::new(),
+ #[cfg(target_os = "macos")]
+ shared_values.enable_filtering_resolver,
+ )
+ .err();
+
+ #[cfg(target_os = "macos")]
+ if let Some(dns_config) = host_config {
+ if let Err(err) = shared_values.runtime.block_on(
+ shared_values
+ .filtering_resolver
+ .set_active(Some(dns_config)),
+ ) {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to activate filtering resolver")
+ );
+ return Self::enter(shared_values, ErrorStateCause::FilteringResolverError);
+ }
+ }
+
#[cfg(target_os = "android")]
let block_failure = if !Self::create_blocking_tun(shared_values) {
Some(FirewallPolicyError::Generic)
@@ -91,6 +185,8 @@ impl TunnelState for ErrorState {
(
TunnelStateWrapper::from(ErrorState {
block_reason: block_reason.clone(),
+ #[cfg(target_os = "macos")]
+ allowed_ips: BTreeSet::new(),
}),
TunnelStateTransition::Error(talpid_tunnel::ErrorState::new(
block_reason,
@@ -99,8 +195,9 @@ impl TunnelState for ErrorState {
)
}
+ #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
fn handle_event(
- self,
+ mut self,
runtime: &tokio::runtime::Handle,
commands: &mut TunnelCommandReceiver,
shared_values: &mut SharedTunnelStateValues,
@@ -108,17 +205,127 @@ impl TunnelState for ErrorState {
use self::EventConsequence::*;
match runtime.block_on(commands.next()) {
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AddAllowedIps(allowed_ips, done_tx)) => {
+ let new_addresses = allowed_ips.iter().any(|ip| self.allowed_ips.insert(*ip));
+ if new_addresses {
+ if let Err(err) = self.set_firewall(shared_values) {
+ return NewState(Self::enter(
+ shared_values,
+ ErrorStateCause::SetFirewallPolicyError(err),
+ ));
+ }
+ }
+ let _ = done_tx.send(());
+ SameState(self.into())
+ }
+
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::AllowMacosNetworkCheck(enable, done_tx)) => {
+ let result = if enable {
+ shared_values.enable_filtering_resolver = enable;
+ if let Err(err) = self.set_firewall(shared_values) {
+ return NewState(ErrorState::enter(
+ shared_values,
+ ErrorStateCause::SetFirewallPolicyError(err),
+ ));
+ }
+
+ match shared_values.dns_monitor.get_system_config() {
+ Ok(current_system_config) => {
+ match shared_values.runtime.block_on(
+ shared_values
+ .filtering_resolver
+ .set_active(current_system_config),
+ ) {
+ Ok(_) => {
+ if let Err(err) = shared_values
+ .dns_monitor
+ .set("lo", &[Ipv4Addr::LOCALHOST.into()])
+ {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg(
+ "Failed to configure system to use filtering resolver"
+ )
+ );
+ let _ =
+ done_tx.send(Err(resolver::Error::SystemDnsError(err)));
+ return NewState(ErrorState::enter(
+ shared_values,
+ ErrorStateCause::SetDnsError,
+ ));
+ }
+ Ok(())
+ }
+
+ Err(err) => {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg(
+ "Failed to start filtering resolver"
+ )
+ );
+ Err(err)
+ }
+ }
+ }
+ Err(err) => {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to obtain system DNS config")
+ );
+
+ let _ = done_tx.send(Err(resolver::Error::SystemDnsError(err)));
+ return NewState(ErrorState::enter(
+ shared_values,
+ ErrorStateCause::ReadSystemDnsConfig,
+ ));
+ }
+ }
+ } else {
+ if let Err(err) = shared_values.dns_monitor.reset() {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to reset DNS config")
+ );
+ }
+ shared_values.deactivate_filtering_resolver(enable)
+ };
+ let _ = done_tx.send(result);
+ SameState(self.into())
+ }
+
+ #[cfg(target_os = "macos")]
+ Some(TunnelCommand::HostDnsConfig(host_config)) => {
+ if shared_values.enable_filtering_resolver {
+ if let Err(err) = shared_values
+ .runtime
+ .block_on(shared_values.filtering_resolver.set_active(host_config))
+ {
+ log::error!(
+ "Failed to set apply new DNS config to filtering resolver: {}",
+ err
+ );
+ return NewState(Self::enter(
+ shared_values,
+ ErrorStateCause::FilteringResolverError,
+ ));
+ }
+ }
+ SameState(self.into())
+ }
Some(TunnelCommand::AllowLan(allow_lan)) => {
if let Err(error_state_cause) = shared_values.set_allow_lan(allow_lan) {
NewState(Self::enter(shared_values, error_state_cause))
} else {
- let _ = Self::set_firewall_policy(shared_values);
+ let _ = self.set_firewall(shared_values);
SameState(self.into())
}
}
Some(TunnelCommand::AllowEndpoint(endpoint, tx)) => {
if shared_values.set_allowed_endpoint(endpoint) {
- let _ = Self::set_firewall_policy(shared_values);
+ let _ = self.set_firewall(shared_values);
#[cfg(target_os = "android")]
if !Self::create_blocking_tun(shared_values) {
@@ -147,15 +354,27 @@ impl TunnelState for ErrorState {
Some(TunnelCommand::IsOffline(is_offline)) => {
shared_values.is_offline = is_offline;
if !is_offline && self.block_reason == ErrorStateCause::IsOffline {
+ Self::reset_dns(shared_values);
NewState(ConnectingState::enter(shared_values, 0))
} else {
SameState(self.into())
}
}
- Some(TunnelCommand::Connect) => NewState(ConnectingState::enter(shared_values, 0)),
+ Some(TunnelCommand::Connect) => {
+ Self::reset_dns(shared_values);
+
+ NewState(ConnectingState::enter(shared_values, 0))
+ }
Some(TunnelCommand::Disconnect) | None => {
#[cfg(target_os = "linux")]
shared_values.reset_connectivity_check();
+ #[cfg(target_os = "macos")]
+ if !shared_values.block_when_disconnected {
+ if let Err(err) = shared_values.disable_filtering_resolver() {
+ log::error!("Failed to disable filtering resolver: {}", err);
+ }
+ }
+ Self::reset_dns(shared_values);
NewState(DisconnectedState::enter(shared_values, true))
}
Some(TunnelCommand::Block(reason)) => {
diff --git a/talpid-core/src/tunnel_state_machine/mod.rs b/talpid-core/src/tunnel_state_machine/mod.rs
index 5f33d264ca..dd6ee01e52 100644
--- a/talpid-core/src/tunnel_state_machine/mod.rs
+++ b/talpid-core/src/tunnel_state_machine/mod.rs
@@ -28,6 +28,8 @@ use futures::{
channel::{mpsc, oneshot},
stream, StreamExt,
};
+#[cfg(target_os = "macos")]
+use std::collections::BTreeSet;
#[cfg(target_os = "android")]
use std::os::unix::io::RawFd;
use std::{collections::HashSet, io, net::IpAddr, path::PathBuf, sync::Arc};
@@ -62,6 +64,11 @@ pub enum Error {
#[error(display = "Failed to initialize the route manager")]
InitRouteManagerError(#[error(source)] crate::routing::Error),
+ /// Failed to initialize filtering resolver
+ #[cfg(target_os = "macos")]
+ #[error(display = "Failed to initialize filtering resolver")]
+ InitFilteringResolver(#[error(source)] crate::resolver::Error),
+
/// Failed to initialize tunnel state machine event loop executor
#[error(display = "Failed to initialize tunnel state machine event loop executor")]
ReactorError(#[error(source)] io::Error),
@@ -98,6 +105,8 @@ pub async fn spawn(
state_change_listener: impl Sender<TunnelStateTransition> + Send + 'static,
offline_state_listener: mpsc::UnboundedSender<bool>,
shutdown_tx: oneshot::Sender<()>,
+ #[cfg(target_os = "macos")] exclusion_gid: u32,
+ #[cfg(target_os = "macos")] enable_resolver: bool,
#[cfg(target_os = "android")] android_context: AndroidContext,
) -> Result<Arc<mpsc::UnboundedSender<TunnelCommand>>, Error> {
let (command_tx, command_rx) = mpsc::unbounded();
@@ -124,6 +133,10 @@ pub async fn spawn(
log_dir,
resource_dir,
command_rx,
+ #[cfg(target_os = "macos")]
+ exclusion_gid,
+ #[cfg(target_os = "macos")]
+ enable_resolver,
#[cfg(target_os = "android")]
android_context,
)
@@ -167,6 +180,15 @@ pub enum TunnelCommand {
oneshot::Sender<Result<(), split_tunnel::Error>>,
Vec<OsString>,
),
+ /// Sets IP addresses which should be allowed to pass through the firewall.
+ #[cfg(target_os = "macos")]
+ AddAllowedIps(BTreeSet<IpAddr>, oneshot::Sender<()>),
+ /// Toggles filtering resolver
+ #[cfg(target_os = "macos")]
+ AllowMacosNetworkCheck(bool, oneshot::Sender<Result<(), crate::resolver::Error>>),
+ /// Receive up-to-date system DNS config. It should never contain our changes to the DNS.
+ #[cfg(target_os = "macos")]
+ HostDnsConfig(Option<(String, Vec<IpAddr>)>),
}
type TunnelCommandReceiver = stream::Fuse<mpsc::UnboundedReceiver<TunnelCommand>>;
@@ -199,6 +221,8 @@ impl TunnelStateMachine {
log_dir: Option<PathBuf>,
resource_dir: PathBuf,
commands_rx: mpsc::UnboundedReceiver<TunnelCommand>,
+ #[cfg(target_os = "macos")] exclusion_gid: u32,
+ #[cfg(target_os = "macos")] enable_resolver: bool,
#[cfg(target_os = "android")] android_context: AndroidContext,
) -> Result<Self, Error> {
let runtime = tokio::runtime::Handle::current();
@@ -214,6 +238,8 @@ impl TunnelStateMachine {
InitialFirewallState::None
},
allow_lan: settings.allow_lan,
+ #[cfg(target_os = "macos")]
+ exclusion_gid,
};
let firewall = Firewall::new(args).map_err(Error::InitFirewallError)?;
@@ -227,9 +253,14 @@ impl TunnelStateMachine {
route_manager
.handle()
.map_err(Error::InitRouteManagerError)?,
+ #[cfg(target_os = "macos")]
+ command_tx.clone(),
)
.map_err(Error::InitDnsMonitorError)?;
+ #[cfg(target_os = "macos")]
+ let filtering_resolver = crate::resolver::start_resolver(command_tx.clone()).await?;
+
let (offline_tx, mut offline_rx) = mpsc::unbounded();
let initial_offline_state_tx = offline_state_tx.clone();
tokio::spawn(async move {
@@ -280,16 +311,24 @@ impl TunnelStateMachine {
resource_dir,
#[cfg(target_os = "linux")]
connectivity_check_was_enabled: None,
+ #[cfg(target_os = "macos")]
+ filtering_resolver,
+ #[cfg(target_os = "macos")]
+ enable_filtering_resolver: enable_resolver,
};
- let (initial_state, _) =
- DisconnectedState::enter(&mut shared_values, settings.reset_firewall);
+ tokio::task::spawn_blocking(move || {
+ let (initial_state, _) =
+ DisconnectedState::enter(&mut shared_values, settings.reset_firewall);
- Ok(TunnelStateMachine {
- current_state: Some(initial_state),
- commands: commands_rx.fuse(),
- shared_values,
+ Ok(TunnelStateMachine {
+ current_state: Some(initial_state),
+ commands: commands_rx.fuse(),
+ shared_values,
+ })
})
+ .await
+ .unwrap()
}
fn run(mut self, change_listener: impl Sender<TunnelStateTransition> + Send + 'static) {
@@ -367,6 +406,13 @@ struct SharedTunnelStateValues {
/// NetworkManager's connecitivity check state.
#[cfg(target_os = "linux")]
connectivity_check_was_enabled: Option<bool>,
+
+ /// Filtering resolver handle
+ #[cfg(target_os = "macos")]
+ filtering_resolver: crate::resolver::ResolverHandle,
+ /// Whether filtering resolver should be enabled
+ #[cfg(target_os = "macos")]
+ enable_filtering_resolver: bool,
}
impl SharedTunnelStateValues {
@@ -392,6 +438,29 @@ impl SharedTunnelStateValues {
Ok(())
}
+ /// Sets the filtering resolver setting and toggles it's state to either inactive or shutdown
+ /// state.
+ #[cfg(target_os = "macos")]
+ pub fn deactivate_filtering_resolver(
+ &mut self,
+ enable_resolver: bool,
+ ) -> Result<(), crate::resolver::Error> {
+ self.enable_filtering_resolver = enable_resolver;
+ self.disable_filtering_resolver()
+ }
+
+ /// Toggles filtering resolver state to either inactive or shutdown.
+ #[cfg(target_os = "macos")]
+ pub fn disable_filtering_resolver(&mut self) -> Result<(), crate::resolver::Error> {
+ if self.enable_filtering_resolver {
+ self.runtime
+ .block_on(self.filtering_resolver.set_inactive())?;
+ } else {
+ self.runtime.block_on(self.filtering_resolver.shutdown())?;
+ }
+ Ok(())
+ }
+
pub fn set_allowed_endpoint(&mut self, endpoint: AllowedEndpoint) -> bool {
if self.allowed_endpoint != endpoint {
#[cfg(target_os = "android")]
diff --git a/talpid-types/src/tunnel.rs b/talpid-types/src/tunnel.rs
index 6be99ac31e..9edf45e07f 100644
--- a/talpid-types/src/tunnel.rs
+++ b/talpid-types/src/tunnel.rs
@@ -106,6 +106,22 @@ pub enum ErrorStateCause {
/// Error reported by split tunnel module.
#[cfg(target_os = "windows")]
SplitTunnelError,
+ /// Failed to start filtering resolver
+ #[cfg(target_os = "macos")]
+ FilteringResolverError,
+ /// Failed read system DNS config
+ #[cfg(target_os = "macos")]
+ ReadSystemDnsConfig,
+}
+
+impl ErrorStateCause {
+ #[cfg(target_os = "macos")]
+ pub fn prevents_filtering_resolver(&self) -> bool {
+ match self {
+ Self::FilteringResolverError | Self::ReadSystemDnsConfig | Self::SetDnsError => true,
+ _ => false,
+ }
+ }
}
/// Errors that can occur when generating tunnel parameters.
@@ -198,6 +214,10 @@ impl fmt::Display for ErrorStateCause {
VpnPermissionDenied => "The Android VPN permission was denied when creating the tunnel",
#[cfg(target_os = "windows")]
SplitTunnelError => "The split tunneling module reported an error",
+ #[cfg(target_os = "macos")]
+ FilteringResolverError => "Failed to set up custom resolver",
+ #[cfg(target_os = "macos")]
+ ReadSystemDnsConfig => "Failed to read system DNS config",
};
write!(f, "{}", description)