Skip to content

Commit

Permalink
Merge branch 'macos-st-prompt-on-use'
Browse files Browse the repository at this point in the history
  • Loading branch information
dlon committed Oct 29, 2024
2 parents 9be5271 + 240fe6e commit 72b4281
Show file tree
Hide file tree
Showing 13 changed files with 185 additions and 21 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Line wrap the file at 100 chars. Th
#### Windows
- Add experimental support for Windows ARM64.

#### macOS
- Detect whether full disk access is enabled in the split tunneling view.

### Changed
- Replace the draft key encapsulation mechanism Kyber (round 3) with the standardized
ML-KEM (FIPS 203) dito in the handshake for Quantum-resistant tunnels.
Expand Down
4 changes: 4 additions & 0 deletions gui/locales/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,10 @@ msgctxt "split-tunneling-view"
msgid "Please try again or send a problem report."
msgstr ""

msgctxt "split-tunneling-view"
msgid "To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings."
msgstr ""

#. Error message showed in a dialog when an application fails to launch.
msgctxt "split-tunneling-view"
msgid "Unable to launch selection. %(detailedErrorMessage)s"
Expand Down
7 changes: 7 additions & 0 deletions gui/src/main/daemon-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,13 @@ export class DaemonRpc extends GrpcClient {
await this.callBool(this.client.setSplitTunnelState, enabled);
}

public async needFullDiskPermissions(): Promise<boolean> {
const needFullDiskPermissions = await this.callEmpty<BoolValue>(
this.client.needFullDiskPermissions,
);
return needFullDiskPermissions.getValue();
}

public async checkVolumes(): Promise<void> {
await this.callEmpty(this.client.checkVolumes);
}
Expand Down
3 changes: 3 additions & 0 deletions gui/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,9 @@ class ApplicationMain
splitTunneling!.removeApplicationFromCache(application);
return Promise.resolve();
});
IpcMainEventChannel.macOsSplitTunneling.handleNeedFullDiskPermissions(() => {
return this.daemonRpc.needFullDiskPermissions();
});

IpcMainEventChannel.app.handleQuit(() => this.disconnectAndQuit());
IpcMainEventChannel.app.handleOpenUrl(async (url) => {
Expand Down
2 changes: 2 additions & 0 deletions gui/src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ export default class AppRenderer {
IpcRendererEventChannel.splitTunneling.addApplication(application);
public forgetManuallyAddedSplitTunnelingApplication = (application: ISplitTunnelingApplication) =>
IpcRendererEventChannel.splitTunneling.forgetManuallyAddedApplication(application);
public needFullDiskPermissions = () =>
IpcRendererEventChannel.macOsSplitTunneling.needFullDiskPermissions();
public setObfuscationSettings = (obfuscationSettings: ObfuscationSettings) =>
IpcRendererEventChannel.settings.setObfuscationSettings(obfuscationSettings);
public setEnableDaita = (value: boolean) =>
Expand Down
4 changes: 4 additions & 0 deletions gui/src/renderer/components/SmallButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const StyledSmallButton = styled.button<StyledSmallButtonProps>(smallText, (prop
alignItems: 'center',
justifyContent: 'center',

'&&:not(& + &&)': {
marginLeft: '0px',
},

[`${SmallButtonGroupStart} &&`]: {
marginLeft: 0,
marginRight: `${BUTTON_GROUP_GAP}px`,
Expand Down
76 changes: 68 additions & 8 deletions gui/src/renderer/components/SplitTunnelingSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
StyledPageCover,
StyledSearchBar,
StyledSpinnerRow,
StyledSystemSettingsButton,
} from './SplitTunnelingSettingsStyles';
import Switch from './Switch';

Expand Down Expand Up @@ -313,16 +314,36 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro
removeSplitTunnelingApplication,
forgetManuallyAddedSplitTunnelingApplication,
getSplitTunnelingApplications,
needFullDiskPermissions,
setSplitTunnelingState,
} = useAppContext();
const splitTunnelingEnabled = useSelector((state: IReduxState) => state.settings.splitTunneling);
const splitTunnelingEnabledValue = useSelector(
(state: IReduxState) => state.settings.splitTunneling,
);
const splitTunnelingApplications = useSelector(
(state: IReduxState) => state.settings.splitTunnelingApplications,
);

const [searchTerm, setSearchTerm] = useState('');
const [applications, setApplications] = useState<ISplitTunnelingApplication[]>();

const [splitTunnelingAvailable, setSplitTunnelingAvailable] = useState(
window.env.platform === 'darwin' ? undefined : true,
);

const splitTunnelingEnabled = splitTunnelingEnabledValue && (splitTunnelingAvailable ?? false);

const fetchNeedFullDiskPermissions = useCallback(async () => {
const needPermissions = await needFullDiskPermissions();
setSplitTunnelingAvailable(!needPermissions);
}, [needFullDiskPermissions]);

useEffect((): void | (() => void) => {
if (window.env.platform === 'darwin') {
void fetchNeedFullDiskPermissions();
}
}, [fetchNeedFullDiskPermissions]);

const onMount = useEffectEvent(async () => {
const { fromCache, applications } = await getSplitTunnelingApplications();
setApplications(applications);
Expand Down Expand Up @@ -441,14 +462,25 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro
<SettingsHeader>
<StyledHeaderTitleContainer>
<StyledHeaderTitle>{strings.splitTunneling}</StyledHeaderTitle>
<Switch isOn={splitTunnelingEnabled} onChange={setSplitTunnelingState} />
<Switch
isOn={splitTunnelingEnabled}
disabled={!splitTunnelingAvailable}
onChange={setSplitTunnelingState}
/>
</StyledHeaderTitleContainer>
<HeaderSubTitle>
{messages.pgettext(
'split-tunneling-view',
'Choose the apps you want to exclude from the VPN tunnel.',
)}
</HeaderSubTitle>
<MacOsSplitTunnelingAvailability
needFullDiskPermissions={
window.env.platform === 'darwin' && splitTunnelingAvailable === false
}
/>
{splitTunnelingAvailable ? (
<HeaderSubTitle>
{messages.pgettext(
'split-tunneling-view',
'Choose the apps you want to exclude from the VPN tunnel.',
)}
</HeaderSubTitle>
) : null}
</SettingsHeader>

{splitTunnelingEnabled && (
Expand Down Expand Up @@ -495,6 +527,34 @@ export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsPro
);
}

interface MacOsSplitTunnelingAvailabilityProps {
needFullDiskPermissions: boolean;
}

function MacOsSplitTunnelingAvailability({
needFullDiskPermissions,
}: MacOsSplitTunnelingAvailabilityProps) {
const { showFullDiskAccessSettings } = useAppContext();

return (
<>
{needFullDiskPermissions === true ? (
<>
<HeaderSubTitle>
{messages.pgettext(
'split-tunneling-view',
'To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings.',
)}
</HeaderSubTitle>
<StyledSystemSettingsButton onClick={showFullDiskAccessSettings}>
Open System Settings
</StyledSystemSettingsButton>
</>
) : null}
</>
);
}

interface IApplicationListProps<T extends IApplication> {
applications: T[] | undefined;
rowRenderer: (application: T) => React.ReactElement;
Expand Down
6 changes: 6 additions & 0 deletions gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ImageView from './ImageView';
import { NavigationScrollbars } from './NavigationBar';
import SearchBar from './SearchBar';
import { HeaderTitle } from './SettingsHeader';
import { SmallButton } from './SmallButton';

export const StyledPageCover = styled.div<{ $show: boolean }>((props) => ({
position: 'absolute',
Expand Down Expand Up @@ -122,3 +123,8 @@ export const StyledSearchBar = styled(SearchBar)({
marginRight: measurements.viewMargin,
marginBottom: measurements.buttonVerticalMargin,
});

export const StyledSystemSettingsButton = styled(SmallButton)({
width: '100%',
marginTop: '24px',
});
3 changes: 3 additions & 0 deletions gui/src/shared/ipc-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ export const ipcSchema = {
getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(),
launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(),
},
macOsSplitTunneling: {
needFullDiskPermissions: invoke<void, boolean>(),
},
splitTunneling: {
'': notifyRenderer<ISplitTunnelingApplication[]>(),
setState: invoke<boolean, void>(),
Expand Down
12 changes: 12 additions & 0 deletions mullvad-daemon/src/management_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,18 @@ impl ManagementService for ManagementServiceImpl {
}))
}

#[cfg(target_os = "macos")]
async fn need_full_disk_permissions(&self, _: Request<()>) -> ServiceResult<bool> {
log::debug!("need_full_disk_permissions");
let has_access = talpid_core::split_tunnel::has_full_disk_access().await;
Ok(Response::new(!has_access))
}

#[cfg(not(target_os = "macos"))]
async fn need_full_disk_permissions(&self, _: Request<()>) -> ServiceResult<bool> {
Ok(Response::new(false))
}

#[cfg(windows)]
async fn check_volumes(&self, _: Request<()>) -> ServiceResult<()> {
log::debug!("check_volumes");
Expand Down
3 changes: 3 additions & 0 deletions mullvad-management-interface/proto/management_interface.proto
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ service ManagementService {
rpc InitPlayPurchase(google.protobuf.Empty) returns (PlayPurchasePaymentToken) {}
rpc VerifyPlayPurchase(PlayPurchase) returns (google.protobuf.Empty) {}

// Check whether the app needs TCC approval for split tunneling (macOS)
rpc NeedFullDiskPermissions(google.protobuf.Empty) returns (google.protobuf.BoolValue) {}

// Notify the split tunnel monitor that a volume was mounted or dismounted
// (Windows).
rpc CheckVolumes(google.protobuf.Empty) returns (google.protobuf.Empty) {}
Expand Down
4 changes: 4 additions & 0 deletions talpid-core/src/split_tunnel/macos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ mod tun;
use crate::tunnel_state_machine::TunnelCommand;
pub use tun::VpnInterface;

/// Check whether the current process has full-disk access enabled.
/// This is required by the process monitor.
pub use process::has_full_disk_access;

/// Errors caused by split tunneling
#[derive(Debug, Clone)]
pub struct Error {
Expand Down
79 changes: 66 additions & 13 deletions talpid-core/src/split_tunnel/macos/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ use std::{
use talpid_macos::process::{list_pids, process_path};
use talpid_platform_metadata::MacosVersion;
use talpid_types::tunnel::ErrorStateCause;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::{
io::{AsyncBufReadExt, BufReader},
sync::OnceCell,
};

const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
const EARLY_FAIL_TIMEOUT: Duration = Duration::from_millis(500);
const EARLY_FAIL_TIMEOUT: Duration = Duration::from_millis(100);

static MIN_OS_VERSION: LazyLock<MacosVersion> =
LazyLock::new(|| MacosVersion::from_raw_version("13.0.0").unwrap());
Expand Down Expand Up @@ -75,21 +78,16 @@ pub struct ProcessMonitorHandle {
impl ProcessMonitor {
pub async fn spawn() -> Result<ProcessMonitorHandle, Error> {
check_os_version_support()?;
let states = ProcessStates::new()?;

if !has_full_disk_access().await {
return Err(Error::NeedFullDiskPermissions);
}

let states = ProcessStates::new()?;
let proc = spawn_eslogger()?;
let (stop_proc_tx, stop_rx): (_, oneshot::Receiver<oneshot::Sender<_>>) =
oneshot::channel();
let mut proc_task = tokio::spawn(handle_eslogger_output(proc, states.clone(), stop_rx));

match tokio::time::timeout(EARLY_FAIL_TIMEOUT, &mut proc_task).await {
// On timeout, all is well
Err(_) => (),
// The process returned an error
Ok(Ok(Err(error))) => return Err(error),
Ok(Ok(Ok(()))) => unreachable!("process monitor stopped prematurely"),
Ok(Err(_)) => unreachable!("process monitor panicked"),
}
let proc_task = tokio::spawn(handle_eslogger_output(proc, states.clone(), stop_rx));

Ok(ProcessMonitorHandle {
stop_proc_tx: Some(stop_proc_tx),
Expand All @@ -99,6 +97,61 @@ impl ProcessMonitor {
}
}

/// Return whether the process has full-disk access
pub async fn has_full_disk_access() -> bool {
static HAS_TCC_APPROVAL: OnceCell<bool> = OnceCell::const_new();
*HAS_TCC_APPROVAL
.get_or_try_init(|| async { has_full_disk_access_inner().await })
.await
.unwrap_or(&true)
}

async fn has_full_disk_access_inner() -> Result<bool, Error> {
let mut proc = spawn_eslogger()?;

let stdout = proc.stdout.take().unwrap();
let stderr = proc.stderr.take().unwrap();

let stderr = BufReader::new(stderr);
let mut stderr_lines = stderr.lines();

let stdout = BufReader::new(stdout);
let mut stdout_lines = stdout.lines();

let mut find_err = tokio::spawn(async move {
tokio::select! {
Ok(Some(line)) = stderr_lines.next_line() => {
!matches!(
parse_eslogger_error(&line),
Some(Error::NeedFullDiskPermissions),
)
}
Ok(Some(_)) = stdout_lines.next_line() => {
// Received output, but not an err
true
}
else => true,
}
});

drop(proc.stdin.take());

let proc = tokio::time::timeout(EARLY_FAIL_TIMEOUT, proc.wait());

tokio::select! {
// Received standard err/out
found_err = &mut find_err => {
Ok(found_err.expect("find_err panicked"))
}
// Process exited
Ok(Ok(_exit_status)) = proc => {
Ok(find_err.await.expect("find_err panicked"))
}
// Timeout
else => Ok(true),
}
}

/// Run until the process exits or `stop_rx` is signaled
async fn handle_eslogger_output(
mut proc: tokio::process::Child,
Expand Down

0 comments on commit 72b4281

Please sign in to comment.