diff options
| author | Oskar <oskar@mullvad.net> | 2024-08-30 20:04:23 +0200 |
|---|---|---|
| committer | Oskar <oskar@mullvad.net> | 2024-08-30 20:04:23 +0200 |
| commit | 3454ded75109ec3dd360295d367a1748a427fef7 (patch) | |
| tree | 4da22da638555f86a666d7007302b99c8b8260a6 | |
| parent | 9dbb580eb0c19ceb24275a58e7d8dc2a92a77482 (diff) | |
| parent | 93dd4c574ca0f7bae46b9ab8a259e465ab856069 (diff) | |
| download | mullvadvpn-3454ded75109ec3dd360295d367a1748a427fef7.tar.xz mullvadvpn-3454ded75109ec3dd360295d367a1748a427fef7.zip | |
Merge branch 'add-wireguard-over-shadowsocks-to-gui-des-967'
21 files changed, 634 insertions, 132 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 37fbe8822b..4695c1bc34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,8 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Added -- Add WireGuard over Shadowsocks obfuscation to the CLI. It can be enabled with - `mullvad obfuscation set mode shadowsocks`. This will also be used automatically when connecting - fails with other methods. +- Add WireGuard over Shadowsocks obfuscation. It can be enabled in "WireGuard settings". This will + also be used automatically when connecting fails with other methods. #### Windows - Add experimental support for Windows ARM64. diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index a6bf039ec7..d35bfa0453 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -357,12 +357,20 @@ msgctxt "accessibility" msgid "Select location. Current location is %(location)s" msgstr "" +msgctxt "accessibility" +msgid "Shadowsocks settings" +msgstr "" + #. Provided to accessibility tools such as screenreaders to describe #. the button which unobscures the account number. msgctxt "accessibility" msgid "Show account number" msgstr "" +msgctxt "accessibility" +msgid "UDP-over-TCP settings" +msgstr "" + #. Title label in navigation bar msgctxt "account-view" msgid "Account" @@ -2036,6 +2044,16 @@ msgctxt "wireguard-settings-nav" msgid "%(wireguard)s settings" msgstr "" +#. Title label in navigation bar +msgctxt "wireguard-settings-nav" +msgid "Shadowsocks" +msgstr "" + +#. Title label in navigation bar +msgctxt "wireguard-settings-nav" +msgid "UDP-over-TCP" +msgstr "" + msgctxt "wireguard-settings-view" msgid "%(daita)s (%(daitaFull)s) hides patterns in your encrypted VPN traffic. If anyone is monitoring your connection, this makes it significantly harder for them to identify what websites you are visiting. It does this by carefully adding network noise and making all network packets the same size." msgstr "" @@ -2071,11 +2089,14 @@ msgid "Obfuscation hides the WireGuard traffic inside another protocol. It can b msgstr "" msgctxt "wireguard-settings-view" -msgid "On (UDP-over-TCP)" +msgid "Port" msgstr "" +#. Text showing currently selected port. +#. Available placeholders: +#. %(port)s - Can be either a number between 1 and 65000 or the text "Automatic". msgctxt "wireguard-settings-view" -msgid "Port" +msgid "Port: %(port)s" msgstr "" #. The title for the WireGuard quantum resistance selector. This setting @@ -2095,6 +2116,10 @@ msgid "Set %(wireguard)s MTU value. Valid range: %(min)d - %(max)d." msgstr "" msgctxt "wireguard-settings-view" +msgid "Shadowsocks" +msgstr "" + +msgctxt "wireguard-settings-view" msgid "The automatic setting will randomly choose from the valid port ranges shown below." msgstr "" @@ -2119,9 +2144,18 @@ msgid "This feature makes the WireGuard tunnel resistant to potential attacks fr msgstr "" msgctxt "wireguard-settings-view" +msgid "UDP-over-TCP" +msgstr "" + +msgctxt "wireguard-settings-view" msgid "UDP-over-TCP port" msgstr "" +#. Text describing the valid port range for a port selector. +msgctxt "wireguard-settings-view" +msgid "Valid range: %(min)s - %(max)s" +msgstr "" + msgctxt "wireguard-settings-view" msgid "Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server." msgstr "" @@ -2387,6 +2421,9 @@ msgstr "" msgid "Not found" msgstr "" +msgid "On (UDP-over-TCP)" +msgstr "" + msgid "Overrides active" msgstr "" diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index f328329d81..c85189cea7 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -29,6 +29,7 @@ import { DeviceEvent, DeviceState, DirectMethod, + EndpointObfuscationType, ErrorStateCause, ErrorStateDetails, FeatureIndicator, @@ -388,6 +389,11 @@ export class DaemonRpc { grpcTypes.ObfuscationSettings.SelectedObfuscation.OFF, ); break; + case ObfuscationType.shadowsocks: + grpcObfuscationSettings.setSelectedObfuscation( + grpcTypes.ObfuscationSettings.SelectedObfuscation.SHADOWSOCKS, + ); + break; case ObfuscationType.udp2tcp: grpcObfuscationSettings.setSelectedObfuscation( grpcTypes.ObfuscationSettings.SelectedObfuscation.UDP2TCP, @@ -403,7 +409,13 @@ export class DaemonRpc { grpcObfuscationSettings.setUdp2tcp(grpcUdp2tcpSettings); } - grpcObfuscationSettings.setShadowsocks(new grpcTypes.ShadowsocksSettings()); + if (obfuscationSettings.shadowsocksSettings) { + const shadowsocksSettings = new grpcTypes.ShadowsocksSettings(); + if (obfuscationSettings.shadowsocksSettings.port !== 'any') { + shadowsocksSettings.setPort(obfuscationSettings.shadowsocksSettings.port.only); + } + grpcObfuscationSettings.setShadowsocks(shadowsocksSettings); + } await this.call<grpcTypes.ObfuscationSettings, Empty>( this.client.setObfuscationSettings, @@ -1200,17 +1212,22 @@ function convertFromProxyEndpoint(proxyEndpoint: grpcTypes.ProxyEndpoint.AsObjec function convertFromObfuscationEndpoint( obfuscationEndpoint: grpcTypes.ObfuscationEndpoint.AsObject, ): IObfuscationEndpoint { - // TODO: Handle Shadowsocks (and other implemented protocols) - if ( - obfuscationEndpoint.obfuscationType !== grpcTypes.ObfuscationEndpoint.ObfuscationType.UDP2TCP - ) { - throw new Error('unsupported obfuscation protocol'); + let obfuscationType: EndpointObfuscationType; + switch (obfuscationEndpoint.obfuscationType) { + case grpcTypes.ObfuscationEndpoint.ObfuscationType.UDP2TCP: + obfuscationType = 'udp2tcp'; + break; + case grpcTypes.ObfuscationEndpoint.ObfuscationType.SHADOWSOCKS: + obfuscationType = 'shadowsocks'; + break; + default: + throw new Error('unsupported obfuscation protocol'); } return { ...obfuscationEndpoint, protocol: convertFromTransportProtocol(obfuscationEndpoint.protocol), - obfuscationType: 'udp2tcp', + obfuscationType: obfuscationType, }; } @@ -1470,6 +1487,9 @@ function convertFromObfuscationSettings( case grpcTypes.ObfuscationSettings.SelectedObfuscation.UDP2TCP: selectedObfuscationType = ObfuscationType.udp2tcp; break; + case grpcTypes.ObfuscationSettings.SelectedObfuscation.SHADOWSOCKS: + selectedObfuscationType = ObfuscationType.shadowsocks; + break; } return { @@ -1477,6 +1497,9 @@ function convertFromObfuscationSettings( udp2tcpSettings: obfuscationSettings?.udp2tcp ? { port: convertFromConstraint(obfuscationSettings.udp2tcp.port) } : { port: 'any' }, + shadowsocksSettings: obfuscationSettings?.shadowsocks + ? { port: convertFromConstraint(obfuscationSettings.shadowsocks.port) } + : { port: 'any' }, }; } diff --git a/gui/src/main/default-settings.ts b/gui/src/main/default-settings.ts index e942a535b6..e11a7434e1 100644 --- a/gui/src/main/default-settings.ts +++ b/gui/src/main/default-settings.ts @@ -74,6 +74,9 @@ export function getDefaultSettings(): ISettings { udp2tcpSettings: { port: 'any', }, + shadowsocksSettings: { + port: 'any', + }, }, customLists: [], apiAccessMethods: getDefaultApiAccessMethods(), diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index ca17d76f1f..e90729507c 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -29,10 +29,12 @@ import SelectLanguage from './SelectLanguage'; import Settings from './Settings'; import SettingsImport from './SettingsImport'; import SettingsTextImport from './SettingsTextImport'; +import Shadowsocks from './Shadowsocks'; import SplitTunnelingSettings from './SplitTunnelingSettings'; import Support from './Support'; import TooManyDevices from './TooManyDevices'; import TransitionContainer, { TransitionView } from './TransitionContainer'; +import UdpOverTcp from './UdpOverTcp'; import UserInterfaceSettings from './UserInterfaceSettings'; import VpnSettings from './VpnSettings'; import WireguardSettings from './WireguardSettings'; @@ -83,6 +85,8 @@ export default function AppRouter() { <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} /> <Route exact path={RoutePath.vpnSettings} component={VpnSettings} /> <Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} /> + <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcp} /> + <Route exact path={RoutePath.shadowsocks} component={Shadowsocks} /> <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} /> <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} /> diff --git a/gui/src/renderer/components/Shadowsocks.tsx b/gui/src/renderer/components/Shadowsocks.tsx new file mode 100644 index 0000000000..0614bc44cf --- /dev/null +++ b/gui/src/renderer/components/Shadowsocks.tsx @@ -0,0 +1,137 @@ +import { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { wrapConstraint } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { removeNonNumericCharacters } from '../../shared/string-helpers'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { useSelector } from '../redux/store'; +import { AriaDescription, AriaInputGroup } from './AriaGroup'; +import * as Cell from './cell'; +import { SelectorItem, SelectorWithCustomItem } from './cell/Selector'; +import { BackAction } from './KeyboardNavigation'; +import { Layout, SettingsContainer } from './Layout'; +import { + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderTitle } from './SettingsHeader'; + +const PORTS: Array<SelectorItem<number>> = []; +const ALLOWED_RANGE = [1, 65000]; + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); + +const StyledSelectorContainer = styled.div({ + flex: 0, +}); + +export default function Shadowsocks() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('wireguard-settings-nav', 'Shadowsocks') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('wireguard-settings-view', 'Shadowsocks')} + </HeaderTitle> + </SettingsHeader> + + <StyledContent> + <Cell.Group> + <ShadowsocksPortSelector /> + </Cell.Group> + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} + +function ShadowsocksPortSelector() { + const { setObfuscationSettings } = useAppContext(); + const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); + + const port = + obfuscationSettings.shadowsocksSettings.port === 'any' + ? null + : obfuscationSettings.shadowsocksSettings.port.only; + + const setShadowsocksPort = useCallback( + async (port: number | null) => { + await setObfuscationSettings({ + ...obfuscationSettings, + shadowsocksSettings: { + ...obfuscationSettings.shadowsocksSettings, + port: wrapConstraint(port), + }, + }); + }, + [setObfuscationSettings, obfuscationSettings], + ); + + const parseValue = useCallback((port: string) => parseInt(port), []); + + const validateValue = useCallback( + (value: number) => value >= ALLOWED_RANGE[0] && value <= ALLOWED_RANGE[1], + [], + ); + + return ( + <AriaInputGroup> + <StyledSelectorContainer> + <SelectorWithCustomItem + // TRANSLATORS: The title for the WireGuard port selector. + title={messages.pgettext('wireguard-settings-view', 'Port')} + items={PORTS} + value={port} + onSelect={setShadowsocksPort} + inputPlaceholder={messages.pgettext('wireguard-settings-view', 'Port')} + automaticValue={null} + parseValue={parseValue} + modifyValue={removeNonNumericCharacters} + validateValue={validateValue} + maxLength={`${ALLOWED_RANGE[1]}`.length} + /> + </StyledSelectorContainer> + <Cell.CellFooter> + <AriaDescription> + <Cell.CellFooterText> + {sprintf( + // TRANSLATORS: Text describing the valid port range for a port selector. + messages.pgettext('wireguard-settings-view', 'Valid range: %(min)s - %(max)s'), + { min: ALLOWED_RANGE[0], max: ALLOWED_RANGE[1] }, + )} + </Cell.CellFooterText> + </AriaDescription> + </Cell.CellFooter> + </AriaInputGroup> + ); +} diff --git a/gui/src/renderer/components/UdpOverTcp.tsx b/gui/src/renderer/components/UdpOverTcp.tsx new file mode 100644 index 0000000000..7179daca77 --- /dev/null +++ b/gui/src/renderer/components/UdpOverTcp.tsx @@ -0,0 +1,126 @@ +import { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; + +import { liftConstraint, LiftedConstraint, wrapConstraint } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { useSelector } from '../redux/store'; +import { AriaInputGroup } from './AriaGroup'; +import * as Cell from './cell'; +import Selector, { SelectorItem } from './cell/Selector'; +import { BackAction } from './KeyboardNavigation'; +import { Layout, SettingsContainer } from './Layout'; +import { ModalMessage } from './Modal'; +import { + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderTitle } from './SettingsHeader'; + +const UDP2TCP_PORTS = [80, 5001]; + +function mapPortToSelectorItem(value: number): SelectorItem<number> { + return { label: value.toString(), value }; +} + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); + +const StyledSelectorContainer = styled.div({ + flex: 0, +}); + +export default function UdpOverTcp() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('wireguard-settings-nav', 'UDP-over-TCP') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('wireguard-settings-view', 'UDP-over-TCP')} + </HeaderTitle> + </SettingsHeader> + + <StyledContent> + <Cell.Group> + <Udp2tcpPortSetting /> + </Cell.Group> + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} + +function Udp2tcpPortSetting() { + const { setObfuscationSettings } = useAppContext(); + const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); + + const port = liftConstraint(obfuscationSettings.udp2tcpSettings.port); + const portItems: SelectorItem<number>[] = useMemo( + () => UDP2TCP_PORTS.map(mapPortToSelectorItem), + [], + ); + + const selectPort = useCallback( + async (port: LiftedConstraint<number>) => { + await setObfuscationSettings({ + ...obfuscationSettings, + udp2tcpSettings: { + ...obfuscationSettings.udp2tcpSettings, + port: wrapConstraint(port), + }, + }); + }, + [setObfuscationSettings, obfuscationSettings], + ); + + return ( + <AriaInputGroup> + <StyledSelectorContainer> + <Selector + // TRANSLATORS: The title for the UDP-over-TCP port selector. + title={messages.pgettext('wireguard-settings-view', 'UDP-over-TCP port')} + details={ + <ModalMessage> + {messages.pgettext( + 'wireguard-settings-view', + 'Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server.', + )} + </ModalMessage> + } + items={portItems} + value={port} + onSelect={selectPort} + thinTitle + automaticValue={'any' as const} + /> + </StyledSelectorContainer> + </AriaInputGroup> + ); +} diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx index df714866cf..1f4709159f 100644 --- a/gui/src/renderer/components/WireguardSettings.tsx +++ b/gui/src/renderer/components/WireguardSettings.tsx @@ -4,9 +4,8 @@ import styled from 'styled-components'; import { strings } from '../../config.json'; import { + Constraint, IpVersion, - liftConstraint, - LiftedConstraint, ObfuscationType, wrapConstraint, } from '../../shared/daemon-rpc-types'; @@ -16,6 +15,7 @@ import { removeNonNumericCharacters } from '../../shared/string-helpers'; import { useAppContext } from '../context'; import { useRelaySettingsUpdater } from '../lib/constraint-updater'; import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; import { useBoolean } from '../lib/utilityHooks'; import { useSelector } from '../redux/store'; import * as AppButton from './AppButton'; @@ -38,7 +38,6 @@ import SettingsHeader, { HeaderTitle } from './SettingsHeader'; const MIN_WIREGUARD_MTU_VALUE = 1280; const MAX_WIREGUARD_MTU_VALUE = 1420; const WIREUGARD_UDP_PORTS = [51820, 53]; -const UDP2TCP_PORTS = [80, 5001]; function mapPortToSelectorItem(value: number): SelectorItem<number> { return { label: value.toString(), value }; @@ -96,7 +95,6 @@ export default function WireguardSettings() { <Cell.Group> <ObfuscationSettings /> - <Udp2tcpPortSetting /> </Cell.Group> <Cell.Group> @@ -212,12 +210,35 @@ function ObfuscationSettings() { const { setObfuscationSettings } = useAppContext(); const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); + // TRANSLATORS: Text showing currently selected port. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(port)s - Can be either a number between 1 and 65000 or the text "Automatic". + const subLabelTemplate = messages.pgettext('wireguard-settings-view', 'Port: %(port)s'); + const obfuscationType = obfuscationSettings.selectedObfuscation; const obfuscationTypeItems: SelectorItem<ObfuscationType>[] = useMemo( () => [ { - label: messages.pgettext('wireguard-settings-view', 'On (UDP-over-TCP)'), + label: messages.pgettext('wireguard-settings-view', 'Shadowsocks'), + subLabel: sprintf(subLabelTemplate, { + port: formatPortForSubLabel(obfuscationSettings.shadowsocksSettings.port), + }), + value: ObfuscationType.shadowsocks, + details: { + path: RoutePath.shadowsocks, + ariaLabel: messages.pgettext('accessibility', 'Shadowsocks settings'), + }, + }, + { + label: messages.pgettext('wireguard-settings-view', 'UDP-over-TCP'), + subLabel: sprintf(subLabelTemplate, { + port: formatPortForSubLabel(obfuscationSettings.udp2tcpSettings.port), + }), value: ObfuscationType.udp2tcp, + details: { + path: RoutePath.udpOverTcp, + ariaLabel: messages.pgettext('accessibility', 'UDP-over-TCP settings'), + }, }, { label: messages.gettext('Off'), @@ -255,62 +276,15 @@ function ObfuscationSettings() { value={obfuscationType} onSelect={selectObfuscationType} automaticValue={ObfuscationType.auto} + automaticTestId="automatic-obfuscation" /> </StyledSelectorContainer> </AriaInputGroup> ); } -function Udp2tcpPortSetting() { - const { setObfuscationSettings } = useAppContext(); - const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); - - const port = liftConstraint(obfuscationSettings.udp2tcpSettings.port); - const portItems: SelectorItem<number>[] = useMemo( - () => UDP2TCP_PORTS.map(mapPortToSelectorItem), - [], - ); - - const expandableProps = useMemo(() => ({ expandable: true, id: 'udp2tcp-port' }), []); - - const selectPort = useCallback( - async (port: LiftedConstraint<number>) => { - await setObfuscationSettings({ - ...obfuscationSettings, - udp2tcpSettings: { - ...obfuscationSettings.udp2tcpSettings, - port: wrapConstraint(port), - }, - }); - }, - [setObfuscationSettings, obfuscationSettings], - ); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - // TRANSLATORS: The title for the UDP-over-TCP port selector. - title={messages.pgettext('wireguard-settings-view', 'UDP-over-TCP port')} - details={ - <ModalMessage> - {messages.pgettext( - 'wireguard-settings-view', - 'Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server.', - )} - </ModalMessage> - } - items={portItems} - value={port} - onSelect={selectPort} - disabled={obfuscationSettings.selectedObfuscation === ObfuscationType.off} - expandable={expandableProps} - thinTitle - automaticValue={'any' as const} - /> - </StyledSelectorContainer> - </AriaInputGroup> - ); +function formatPortForSubLabel(port: Constraint<number>): string { + return port === 'any' ? messages.gettext('Automatic') : `${port.only}`; } function MultihopSetting() { diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx index 6bfeeb3887..3c1988a502 100644 --- a/gui/src/renderer/components/cell/Selector.tsx +++ b/gui/src/renderer/components/cell/Selector.tsx @@ -3,8 +3,11 @@ import styled from 'styled-components'; import { colors } from '../../../config.json'; import { messages } from '../../../shared/gettext'; +import { useHistory } from '../../lib/history'; +import { RoutePath } from '../../lib/routes'; import { useStyledRef } from '../../lib/utilityHooks'; import { AriaDetails, AriaInput, AriaLabel } from '../AriaGroup'; +import ImageView from '../ImageView'; import InfoButton from '../InfoButton'; import * as Cell from '.'; @@ -17,6 +20,8 @@ export interface SelectorItem<T> { value: T; disabled?: boolean; 'data-testid'?: string; + details?: { path: RoutePath; ariaLabel: string }; + subLabel?: string; } // T represents the available values and U represent the value of "Automatic"/"Any" if there is one. @@ -33,6 +38,7 @@ interface CommonSelectorProps<T, U> { thinTitle?: boolean; automaticLabel?: string; automaticValue?: U; + automaticTestId?: string; children?: React.ReactNode | Array<React.ReactNode>; } @@ -53,6 +59,8 @@ export default function Selector<T, U>(props: SelectorProps<T, U>) { disabled={props.disabled || item.disabled} forwardedRef={ref} onSelect={props.onSelect} + subLabel={item.subLabel} + details={item.details} data-testid={item['data-testid']}> {item.label} </SelectorCell> @@ -66,6 +74,7 @@ export default function Selector<T, U>(props: SelectorProps<T, U>) { items.unshift( <SelectorCell key={'automatic'} + data-testid={props.automaticTestId} value={props.automaticValue} isSelected={selected} disabled={props.disabled} @@ -133,39 +142,95 @@ interface SelectorCellProps<T> { isSelected: boolean; disabled?: boolean; onSelect: (value: T) => void; - children: React.ReactNode | Array<React.ReactNode>; + children: string; + subLabel?: string; forwardedRef?: React.Ref<HTMLButtonElement>; 'data-testid'?: string; + details?: SelectorItem<unknown>['details']; } +const StyledSelectorCell = styled.div({ + display: 'flex', +}); + +const StyledSideButtonImage = styled(ImageView)({ + padding: '0 3px', +}); + +const StyledSideButton = styled(Cell.SideButton)({ + marginBottom: '1px', +}); + function SelectorCell<T>(props: SelectorCellProps<T>) { + const history = useHistory(); + const handleClick = useCallback(() => { if (!props.isSelected) { props.onSelect(props.value); } }, [props.isSelected, props.onSelect, props.value]); + const navigate = useCallback(() => { + if (props.details) { + history.push(props.details.path); + } + }, [history.push, props.details?.path]); + return ( - <Cell.CellButton - ref={props.forwardedRef} - onClick={handleClick} - selected={props.isSelected} - disabled={props.disabled} - role="option" - aria-selected={props.isSelected} - aria-disabled={props.disabled} - data-testid={props['data-testid']}> - <StyledCellIcon - $visible={props.isSelected} - source="icon-tick" - width={18} - tintColor={colors.white} - /> - <Cell.ValueLabel>{props.children}</Cell.ValueLabel> - </Cell.CellButton> + <StyledSelectorCell> + <Cell.CellButton + ref={props.forwardedRef} + onClick={handleClick} + selected={props.isSelected} + disabled={props.disabled} + role="option" + aria-selected={props.isSelected} + aria-disabled={props.disabled} + data-testid={props['data-testid']}> + <StyledCellIcon + $visible={props.isSelected} + source="icon-tick" + width={18} + tintColor={colors.white} + /> + <SelectorCellLabel subLabel={props.subLabel}>{props.children}</SelectorCellLabel> + </Cell.CellButton> + {props.details && ( + <StyledSideButton + $backgroundColor={colors.blue40} + $backgroundColorHover={colors.blue80} + aria-label={props.details.ariaLabel} + onClick={navigate}> + <StyledSideButtonImage + source="icon-chevron" + width={7} + tintColor={colors.white} + tintHoverColor={colors.white80} + /> + </StyledSideButton> + )} + </StyledSelectorCell> ); } +interface SelectorCellLabelProps { + children: string; + subLabel?: string; +} + +function SelectorCellLabel(props: SelectorCellLabelProps) { + if (props.subLabel) { + return ( + <Cell.LabelContainer> + <Cell.ValueLabel>{props.children}</Cell.ValueLabel> + {props.subLabel && <Cell.SubLabel>{props.subLabel}</Cell.SubLabel>} + </Cell.LabelContainer> + ); + } else { + return <Cell.ValueLabel>{props.children}</Cell.ValueLabel>; + } +} + interface StyledCustomContainerProps { selected: boolean; } diff --git a/gui/src/renderer/components/cell/SideButton.tsx b/gui/src/renderer/components/cell/SideButton.tsx new file mode 100644 index 0000000000..6c30922b5f --- /dev/null +++ b/gui/src/renderer/components/cell/SideButton.tsx @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { measurements } from '../common-styles'; +import { buttonColor, ButtonColors } from './styles'; + +export const SideButton = styled.button<ButtonColors>(buttonColor, { + position: 'relative', + alignSelf: 'stretch', + paddingLeft: measurements.viewMargin, + paddingRight: measurements.viewMargin, + border: 0, + + '&&::before': { + content: '""', + position: 'absolute', + margin: 'auto', + top: 0, + left: 0, + bottom: 0, + height: '50%', + width: '1px', + backgroundColor: colors.darkBlue, + }, +}); diff --git a/gui/src/renderer/components/cell/index.ts b/gui/src/renderer/components/cell/index.ts index 4e98c9ef3d..2bbd56167a 100644 --- a/gui/src/renderer/components/cell/index.ts +++ b/gui/src/renderer/components/cell/index.ts @@ -6,3 +6,4 @@ export * from './Label'; export * from './Section'; export * from './Group'; export * from './Row'; +export * from './SideButton'; diff --git a/gui/src/renderer/components/cell/styles.ts b/gui/src/renderer/components/cell/styles.ts new file mode 100644 index 0000000000..a2d20b03f6 --- /dev/null +++ b/gui/src/renderer/components/cell/styles.ts @@ -0,0 +1,13 @@ +export interface ButtonColors { + $backgroundColor: string; + $backgroundColorHover: string; +} + +export const buttonColor = (props: ButtonColors) => { + return { + backgroundColor: props.$backgroundColor, + '&&:not(:disabled):hover': { + backgroundColor: props.$backgroundColorHover, + }, + }; +}; diff --git a/gui/src/renderer/components/select-location/CustomLists.tsx b/gui/src/renderer/components/select-location/CustomLists.tsx index f335f7ae87..1cbb692b1b 100644 --- a/gui/src/renderer/components/select-location/CustomLists.tsx +++ b/gui/src/renderer/components/select-location/CustomLists.tsx @@ -12,7 +12,6 @@ import * as Cell from '../cell'; import { measurements } from '../common-styles'; import { BackAction } from '../KeyboardNavigation'; import SimpleInput from '../SimpleInput'; -import { StyledLocationRowIcon } from './LocationRowStyles'; import { useRelayListContext } from './RelayListContext'; import RelayLocationList from './RelayLocationList'; import { useScrollPositionContext } from './ScrollPositionContext'; @@ -42,7 +41,7 @@ const StyledHeaderLabel = styled(Cell.Label)({ lineHeight: measurements.rowMinHeight, }); -const StyledCellButton = styled(StyledLocationRowIcon)({ +const StyledCellButton = styled(Cell.SideButton)({ border: 'none', }); diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx index f76b5d3f61..41f3aa7152 100644 --- a/gui/src/renderer/components/select-location/LocationRow.tsx +++ b/gui/src/renderer/components/select-location/LocationRow.tsx @@ -22,7 +22,6 @@ import { StyledHoverIconButton, StyledLocationRowButton, StyledLocationRowContainer, - StyledLocationRowIcon, StyledLocationRowLabel, } from './LocationRowStyles'; import { @@ -183,7 +182,7 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) { {hasChildren || ('customList' in props.source.location && !('country' in props.source.location)) ? ( - <StyledLocationRowIcon + <Cell.SideButton as={ChevronButton} onClick={toggleCollapse} disabled={!hasChildren} diff --git a/gui/src/renderer/components/select-location/LocationRowStyles.tsx b/gui/src/renderer/components/select-location/LocationRowStyles.tsx index 6a159f4054..58e9ba9606 100644 --- a/gui/src/renderer/components/select-location/LocationRowStyles.tsx +++ b/gui/src/renderer/components/select-location/LocationRowStyles.tsx @@ -3,24 +3,11 @@ import { Styles } from 'styled-components/dist/types'; import { colors } from '../../../config.json'; import * as Cell from '../cell'; +import { buttonColor, ButtonColors } from '../cell/styles'; import { measurements, normalText } from '../common-styles'; import ImageView from '../ImageView'; import InfoButton from '../InfoButton'; -interface ButtonColorProps { - $backgroundColor: string; - $backgroundColorHover: string; -} - -export const buttonColor = (props: ButtonColorProps) => { - return { - backgroundColor: props.$backgroundColor, - '&&:not(:disabled):hover': { - backgroundColor: props.$backgroundColorHover, - }, - }; -}; - export const StyledLocationRowContainer = styled(Cell.Container)({ display: 'flex', padding: 0, @@ -41,7 +28,7 @@ export const StyledLocationRowLabel = styled(Cell.Label)(normalText, { whiteSpace: 'nowrap', }); -export const StyledLocationRowButton = styled(Cell.Row)<ButtonColorProps & { $level: number }>( +export const StyledLocationRowButton = styled(Cell.Row)<ButtonColors & { $level: number }>( buttonColor, (props) => { const paddingLeft = (props.$level + 1) * 16 + 2; @@ -57,32 +44,12 @@ export const StyledLocationRowButton = styled(Cell.Row)<ButtonColorProps & { $le }, ); -export const StyledLocationRowIcon = styled.button<ButtonColorProps>(buttonColor, { - position: 'relative', - alignSelf: 'stretch', - paddingLeft: measurements.viewMargin, - paddingRight: measurements.viewMargin, - border: 0, - - '&&::before': { - content: '""', - position: 'absolute', - margin: 'auto', - top: 0, - left: 0, - bottom: 0, - height: '50%', - width: '1px', - backgroundColor: colors.darkBlue, - }, -}); - interface HoverButtonProps { $isLast?: boolean; } const hoverButton = ( - props: ButtonColorProps & HoverButtonProps, + props: ButtonColors & HoverButtonProps, ): Styles< React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> > => ({ @@ -110,7 +77,7 @@ const hoverButton = ( }, }); -export const StyledHoverIconButton = styled.button<ButtonColorProps & HoverButtonProps>( +export const StyledHoverIconButton = styled.button<ButtonColors & HoverButtonProps>( buttonColor, hoverButton, ); @@ -126,7 +93,7 @@ export const StyledHoverIcon = styled(ImageView).attrs({ }, }); -export const StyledHoverInfoButton = styled(InfoButton)<ButtonColorProps & HoverButtonProps>( +export const StyledHoverInfoButton = styled(InfoButton)<ButtonColors & HoverButtonProps>( buttonColor, hoverButton, ); diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx index a347638b0e..f4ccf13ec2 100644 --- a/gui/src/renderer/components/select-location/SpecialLocationList.tsx +++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx @@ -6,6 +6,7 @@ import { messages } from '../../../shared/gettext'; import { useHistory } from '../../lib/history'; import { RoutePath } from '../../lib/routes'; import { useSelector } from '../../redux/store'; +import * as Cell from '../cell'; import ImageView from '../ImageView'; import InfoButton from '../InfoButton'; import { SpecialLocationIndicator } from '../RelayStatusIndicator'; @@ -14,7 +15,6 @@ import { StyledHoverInfoButton, StyledLocationRowButton, StyledLocationRowContainerWithMargin, - StyledLocationRowIcon, StyledLocationRowLabel, } from './LocationRowStyles'; import { SpecialBridgeLocationType, SpecialLocation } from './select-location-types'; @@ -74,7 +74,7 @@ export function AutomaticLocationRow( <SpecialLocationIndicator /> <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel> </StyledLocationRowButton> - <StyledLocationRowIcon + <Cell.SideButton as={StyledSpecialLocationInfoButton} title={messages.gettext('Automatic')} message={messages.pgettext( @@ -136,7 +136,7 @@ export function CustomBridgeLocationRow( 'A custom bridge server can be used to circumvent censorship when regular Mullvad bridge servers don’t work.', )} /> - <StyledLocationRowIcon + <Cell.SideButton {...background} aria-label={ bridgeConfigured @@ -150,7 +150,7 @@ export function CustomBridgeLocationRow( tintColor={colors.white} tintHoverColor={colors.white80} /> - </StyledLocationRowIcon> + </Cell.SideButton> </StyledLocationRowContainerWithMargin> ); } diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts index 1f2a3c28a7..0ccc3679ff 100644 --- a/gui/src/renderer/lib/routes.ts +++ b/gui/src/renderer/lib/routes.ts @@ -15,6 +15,8 @@ export enum RoutePath { userInterfaceSettings = '/settings/interface', vpnSettings = '/settings/vpn', wireguardSettings = '/settings/advanced/wireguard', + udpOverTcp = '/settings/advanced/wireguard/udp-over-tcp', + shadowsocks = '/settings/advanced/shadowsocks', openVpnSettings = '/settings/advanced/openvpn', splitTunneling = '/settings/split-tunneling', apiAccessMethods = '/settings/api-access-methods', diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index eae413fe22..6eb595467b 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -188,6 +188,9 @@ const initialState: ISettingsReduxState = { udp2tcpSettings: { port: 'any', }, + shadowsocksSettings: { + port: 'any', + }, }, customLists: [], apiAccessMethods: getDefaultApiAccessMethods(), diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index ed588ff811..b84ee8a72e 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -112,7 +112,7 @@ export function tunnelTypeToString(tunnel: TunnelType): string { } export type RelayProtocol = 'tcp' | 'udp'; -export type EndpointObfuscationType = 'udp2tcp'; +export type EndpointObfuscationType = 'udp2tcp' | 'shadowsocks'; export type Constraint<T> = 'any' | { only: T }; export type LiftedConstraint<T> = 'any' | T; @@ -451,15 +451,21 @@ export type Udp2TcpObfuscationSettings = { port: Constraint<number>; }; +export type ShadowsocksSettings = { + port: Constraint<number>; +}; + export enum ObfuscationType { auto, off, udp2tcp, + shadowsocks, } export type ObfuscationSettings = { selectedObfuscation: ObfuscationType; udp2tcpSettings: Udp2TcpObfuscationSettings; + shadowsocksSettings: ShadowsocksSettings; }; export interface IBridgeConstraints { diff --git a/gui/test/e2e/installed/state-dependent/obfuscation.spec.ts b/gui/test/e2e/installed/state-dependent/obfuscation.spec.ts new file mode 100644 index 0000000000..af72ded6d5 --- /dev/null +++ b/gui/test/e2e/installed/state-dependent/obfuscation.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; +import { execSync } from 'child_process'; +import { Page } from 'playwright'; + +import { startInstalledApp } from '../installed-utils'; +import { TestUtils } from '../../utils'; +import { colors } from '../../../../src/config.json'; +import { RoutePath } from '../../../../src/renderer/lib/routes'; + +const SHADOWSOCKS_PORT = 65_000; +const UDPOVERTCP_PORT = '80'; + +// This test sets different obfuscation settings combinations and verifies that it was set in the +// daemon. + +let page: Page; +let util: TestUtils; + +test.beforeAll(async () => { + ({ page, util } = await startInstalledApp()); +}); + +test.afterAll(async () => { + await page.close(); +}); + +test('App should have automatic obfuscation', async () => { + await util.waitForNavigation(async () => await page.click('button[aria-label="Settings"]')); + expect( + await util.waitForNavigation(async () => await page.getByText('VPN settings').click()), + ).toBe(RoutePath.vpnSettings); + + expect( + await util.waitForNavigation(async () => await page.getByText('WireGuard settings').click()), + ).toBe(RoutePath.wireguardSettings); + + const automatic = page.getByTestId('automatic-obfuscation'); + await expect(automatic).toHaveCSS('background-color', colors.green); + + const cliObfuscation = execSync('mullvad obfuscation get').toString().split('\n'); + expect(cliObfuscation[0]).toEqual('Obfuscation mode: auto'); + expect(cliObfuscation[1]).toEqual('udp2tcp settings: any port'); + expect(cliObfuscation[2]).toEqual('Shadowsocks settings: any port'); +}); + +test('App should set obfuscation to shadowsocks with custom port', async () => { + expect( + await util.waitForNavigation( + async () => await page.click('button[aria-label="Shadowsocks settings"]'), + ), + ).toBe(RoutePath.shadowsocks); + + const automatic = page.locator('button', { hasText: 'Automatic' }); + await expect(automatic).toHaveCSS('background-color', colors.green); + + const customInput = page.locator('input[type="text"]'); + await customInput.click(); + await customInput.fill(`${SHADOWSOCKS_PORT}`); + await customInput.blur(); + + const customItem = page.locator('div[role="option"]', { hasText: 'Custom' }); + await expect(customItem).toHaveCSS('background-color', colors.green); + + await util.waitForNavigation(async () => await page.click('button[aria-label="Back"]')); + + const shadowsocksItem = page.locator('button', { hasText: 'Shadowsocks' }); + await shadowsocksItem.click(); + await expect(shadowsocksItem).toHaveCSS('background-color', colors.green); + await expect(shadowsocksItem).toContainText(`Port: ${SHADOWSOCKS_PORT}`); + + const cliObfuscation = execSync('mullvad obfuscation get').toString().split('\n')[2]; + expect(cliObfuscation).toEqual(`Shadowsocks settings: port ${SHADOWSOCKS_PORT}`); +}); + +test('App should still have shadowsocks custom port', async () => { + expect( + await util.waitForNavigation( + async () => await page.click('button[aria-label="Shadowsocks settings"]'), + ), + ).toBe(RoutePath.shadowsocks); + + const customItem = page.locator('div[role="option"]', { hasText: 'Custom' }); + await expect(customItem).toHaveCSS('background-color', colors.green); + + await util.waitForNavigation(async () => await page.click('button[aria-label="Back"]')); +}); + +test('App should set obfuscation to UDP-over-TCP with port', async () => { + expect( + await util.waitForNavigation( + async () => await page.click('button[aria-label="UDP-over-TCP settings"]'), + ), + ).toBe(RoutePath.udpOverTcp); + + const automatic = page.locator('button', { hasText: 'Automatic' }); + await expect(automatic).toHaveCSS('background-color', colors.green); + + const portButton = page.locator('button', { hasText: UDPOVERTCP_PORT }); + await portButton.click(); + + await expect(portButton).toHaveCSS('background-color', colors.green); + + await util.waitForNavigation(async () => await page.click('button[aria-label="Back"]')); + + const udpOverTcpItem = page.locator('button', { hasText: 'UDP-over-TCP' }); + await udpOverTcpItem.click(); + await expect(udpOverTcpItem).toHaveCSS('background-color', colors.green); + await expect(udpOverTcpItem).toContainText(`Port: ${UDPOVERTCP_PORT}`); + + const cliObfuscation = execSync('mullvad obfuscation get').toString().split('\n')[1]; + expect(cliObfuscation).toEqual(`udp2tcp settings: port ${UDPOVERTCP_PORT}`); +}); diff --git a/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts b/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts index af32668efb..53332ee6ab 100644 --- a/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts +++ b/gui/test/e2e/installed/state-dependent/tunnel-state.spec.ts @@ -90,6 +90,13 @@ test('App should show correct WireGuard transport protocol', async () => { await expect(inData).toContainText(new RegExp('UDP$')); }); +test('App should connect with Shadowsocks', async () => { + await exec('mullvad obfuscation set mode shadowsocks'); + await expectConnected(page); + await exec('mullvad obfuscation set mode off'); + await expectConnected(page); +}); + test('App should show correct tunnel protocol', async () => { const tunnelProtocol = page.getByTestId('tunnel-protocol'); await expect(tunnelProtocol).toHaveText('WireGuard'); |
