diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-01-03 14:23:02 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-01-03 14:23:02 +0100 |
| commit | 5e72bac67addd36682443568de651fc05cd6e207 (patch) | |
| tree | 750ac3f0737f4580258c69540e491303ac619461 /gui/src | |
| parent | f4cd4fc51955874f7e7ac13636b63ecfa8f9eaf3 (diff) | |
| parent | c7027fac2b5db7a2a19f1f79137b1a2d371335cb (diff) | |
| download | mullvadvpn-5e72bac67addd36682443568de651fc05cd6e207.tar.xz mullvadvpn-5e72bac67addd36682443568de651fc05cd6e207.zip | |
Merge branch 'add-wg-multihop'
Diffstat (limited to 'gui/src')
24 files changed, 1001 insertions, 372 deletions
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index a2416e9943..edfc3ec4cd 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -862,6 +862,9 @@ function convertFromTunnelStateRelayInfo( tunnelType: convertFromTunnelType(state.tunnelEndpoint.tunnelType), protocol: convertFromTransportProtocol(state.tunnelEndpoint.protocol), proxy: state.tunnelEndpoint.proxy && convertFromProxyEndpoint(state.tunnelEndpoint.proxy), + entryEndpoint: + state.tunnelEndpoint.entryEndpoint && + convertFromEntryEndpoint(state.tunnelEndpoint.entryEndpoint), }, }; } @@ -890,6 +893,13 @@ function convertFromProxyEndpoint(proxyEndpoint: grpcTypes.ProxyEndpoint.AsObjec }; } +function convertFromEntryEndpoint(entryEndpoint: grpcTypes.Endpoint.AsObject) { + return { + address: entryEndpoint.address, + transportProtocol: convertFromTransportProtocol(entryEndpoint.protocol), + }; +} + function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefined { const settingsObject = settings.toObject(); const bridgeState = convertFromBridgeState(settingsObject.bridgeState!.state!); @@ -1172,7 +1182,12 @@ function convertFromOpenVpnConstraints( function convertFromWireguardConstraints( constraints: grpcTypes.WireguardConstraints, ): IWireguardConstraints { - const result: IWireguardConstraints = { port: 'any', ipVersion: 'any' }; + const result: IWireguardConstraints = { + port: 'any', + ipVersion: 'any', + useMultihop: constraints.getUseMultihop(), + entryLocation: 'any', + }; const port = constraints.getPort()?.getPort(); if (port) { @@ -1189,6 +1204,11 @@ function convertFromWireguardConstraints( break; } + const entryLocation = constraints.getEntryLocation(); + if (entryLocation) { + result.entryLocation = { only: convertFromLocation(entryLocation.toObject()) }; + } + return result; } @@ -1310,6 +1330,16 @@ function convertToWireguardConstraints( wireguardConstraints.setIpVersion(ipVersionConstraints); } + if (constraint.useMultihop) { + wireguardConstraints.setUseMultihop(constraint.useMultihop); + } + + const entryLocation = liftConstraint(constraint.entryLocation); + if (entryLocation) { + const entryLocationConstraint = convertToLocation(entryLocation); + wireguardConstraints.setEntryLocation(entryLocationConstraint); + } + return wireguardConstraints; } return undefined; diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 5bce63cc2d..a2c49bcacc 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -158,6 +158,8 @@ class ApplicationMain { wireguardConstraints: { port: 'any', ipVersion: 'any', + useMultihop: false, + entryLocation: 'any', }, }, }, @@ -905,11 +907,7 @@ class ApplicationMain { ) { this.relays = newRelayList; - const filteredRelays = this.processRelaysForPresentation( - newRelayList, - relaySettings, - bridgeState, - ); + const filteredRelays = this.processRelaysForPresentation(newRelayList, relaySettings); const filteredBridges = this.processBridgesForPresentation(newRelayList, bridgeState); if (this.windowController) { @@ -923,7 +921,6 @@ class ApplicationMain { private processRelaysForPresentation( relayList: IRelayList, relaySettings: RelaySettings, - bridgeState: BridgeState, ): IRelayList { const tunnelProtocol = 'normal' in relaySettings ? liftConstraint(relaySettings.normal.tunnelProtocol) : undefined; @@ -943,14 +940,16 @@ class ApplicationMain { case 'wireguard': return relay.tunnels.wireguard.length > 0; - case 'any': - // TODO: Drop win32 check when Wireguard becomes default on Windows - if (process.platform === 'win32' || bridgeState === 'on') { - return relay.tunnels.openvpn.length > 0; + case 'any': { + const useMultihop = + 'normal' in relaySettings && + relaySettings.normal.wireguardConstraints.useMultihop; + if (useMultihop) { + return relay.tunnels.wireguard.length > 0; } else { return relay.tunnels.openvpn.length > 0 || relay.tunnels.wireguard.length > 0; } - + } default: return false; } @@ -1151,11 +1150,7 @@ class ApplicationMain { tunnelState: this.tunnelState, settings: this.settings, relayListPair: { - relays: this.processRelaysForPresentation( - this.relays, - this.settings.relaySettings, - this.settings.bridgeState, - ), + relays: this.processRelaysForPresentation(this.relays, this.settings.relaySettings), bridges: this.processBridgesForPresentation(this.relays, this.settings.bridgeState), }, currentVersion: this.currentVersion, diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 8b4116d647..0419bb0919 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -10,7 +10,7 @@ import { AppContext } from './context'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; import settingsActions from './redux/settings/actions'; -import { IRelayLocationRedux, IWgKey } from './redux/settings/reducers'; +import { IWgKey } from './redux/settings/reducers'; import configureStore from './redux/store'; import userInterfaceActions from './redux/userinterface/actions'; import versionActions from './redux/version/actions'; @@ -33,7 +33,6 @@ import { IAppVersionInfo, IDnsOptions, ILocation, - IRelayList, ISettings, IWireguardPublicKey, KeygenEvent, @@ -88,7 +87,6 @@ export default class AppRenderer { userInterface: bindActionCreators(userInterfaceActions, this.reduxStore.dispatch), }; - private locale = 'en'; private location?: Partial<ILocation>; private lastDisconnectedLocation?: Partial<ILocation>; private relayListPair!: IRelayListPair; @@ -597,7 +595,6 @@ export default class AppRenderer { } private setLocale(locale: string) { - this.locale = locale; this.reduxActions.userInterface.updateLocale(locale); } @@ -624,6 +621,8 @@ export default class AppRenderer { wireguard: { port: liftConstraint(wireguardConstraints.port), ipVersion: liftConstraint(wireguardConstraints.ipVersion), + useMultihop: wireguardConstraints.useMultihop, + entryLocation: liftConstraint(wireguardConstraints.entryLocation), }, tunnelProtocol: liftConstraint(tunnelProtocol), }, @@ -835,36 +834,14 @@ export default class AppRenderer { } } - private convertRelayListToLocationList(relayList: IRelayList): IRelayLocationRedux[] { - return relayList.countries - .map((country) => ({ - name: country.name, - code: country.code, - hasActiveRelays: country.cities.some((city) => city.relays.some((relay) => relay.active)), - cities: country.cities - .map((city) => ({ - name: city.name, - code: city.code, - latitude: city.latitude, - longitude: city.longitude, - hasActiveRelays: city.relays.some((relay) => relay.active), - relays: city.relays.sort((relayA, relayB) => - relayA.hostname.localeCompare(relayB.hostname, this.locale, { numeric: true }), - ), - })) - .sort((cityA, cityB) => cityA.name.localeCompare(cityB.name, this.locale)), - })) - .sort((countryA, countryB) => countryA.name.localeCompare(countryB.name, this.locale)); - } - private setRelayListPair(relayListPair: IRelayListPair) { this.relayListPair = relayListPair; this.propagateRelayListPairToRedux(); } private propagateRelayListPairToRedux() { - const relays = this.convertRelayListToLocationList(this.relayListPair.relays); - const bridges = this.convertRelayListToLocationList(this.relayListPair.bridges); + const relays = this.relayListPair.relays.countries; + const bridges = this.relayListPair.bridges.countries; this.reduxActions.settings.updateRelayLocations(relays); this.reduxActions.settings.updateBridgeLocations(bridges); diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index f511265464..369f5169a2 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -236,7 +236,7 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { type={ModalAlertType.caution} buttons={[ <AppButton.RedButton key="confirm" onClick={this.confirmEnableBlockWhenDisconnected}> - {messages.pgettext('advanced-settings-view', 'Enable anyway')} + {messages.gettext('Enable anyway')} </AppButton.RedButton>, <AppButton.BlueButton key="back" onClick={this.hideConfirmBlockWhenDisconnectedAlert}> {messages.gettext('Back')} diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx index 802797eb77..a62368e166 100644 --- a/gui/src/renderer/components/BridgeLocations.tsx +++ b/gui/src/renderer/components/BridgeLocations.tsx @@ -17,6 +17,7 @@ export enum SpecialBridgeLocationType { interface IBridgeLocationsProps { source: IRelayLocationRedux[]; + locale: string; defaultExpandedLocations?: RelayLocation[]; selectedValue?: LiftedConstraint<RelayLocation>; selectedElementRef?: React.Ref<React.ReactInstance>; @@ -53,6 +54,7 @@ const BridgeLocations = React.forwardRef(function BridgeLocationsT( </SpecialLocations> <RelayLocations source={props.source} + locale={props.locale} onWillExpand={props.onWillExpand} onTransitionEnd={props.onTransitionEnd} /> diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx index d0b0098d50..65a47ea1b3 100644 --- a/gui/src/renderer/components/ConnectionPanel.tsx +++ b/gui/src/renderer/components/ConnectionPanel.tsx @@ -11,6 +11,7 @@ import { } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { default as ConnectionPanelDisclosure } from '../components/ConnectionPanelDisclosure'; +import Marquee from './Marquee'; export interface IEndpoint { ip: string; @@ -35,7 +36,9 @@ interface IProps { isOpen: boolean; hostname?: string; bridgeHostname?: string; + entryHostname?: string; inAddress?: IInAddress; + entryLocationInAddress?: IInAddress; bridgeInfo?: IBridgeData; outAddress?: IOutAddress; onToggle: () => void; @@ -72,15 +75,15 @@ const Header = styled.div({ export default class ConnectionPanel extends React.Component<IProps> { public render() { - const { inAddress, outAddress, bridgeInfo } = this.props; - const entryPoint = bridgeInfo && inAddress ? bridgeInfo : inAddress; + const { outAddress } = this.props; + const entryPoint = this.getEntryPoint(); return ( <div className={this.props.className}> {this.props.hostname && ( <Header> <ConnectionPanelDisclosure pointsUp={this.props.isOpen} onToggle={this.props.onToggle}> - {this.hostnameLine()} + <Marquee>{this.hostnameLine()}</Marquee> </ConnectionPanelDisclosure> </Header> )} @@ -117,17 +120,34 @@ export default class ConnectionPanel extends React.Component<IProps> { ); } + private getEntryPoint() { + const { inAddress, entryLocationInAddress, bridgeInfo } = this.props; + + if (entryLocationInAddress && inAddress) { + return entryLocationInAddress; + } else if (bridgeInfo && inAddress) { + return bridgeInfo; + } else { + return inAddress; + } + } + private hostnameLine() { if (this.props.hostname && this.props.bridgeHostname) { + return sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), { + relay: this.props.hostname, + entry: this.props.bridgeHostname, + }); + } else if (this.props.hostname && this.props.entryHostname) { return sprintf( // TRANSLATORS: The hostname line displayed below the country on the main screen // TRANSLATORS: Available placeholders: // TRANSLATORS: %(relay)s - the relay hostname - // TRANSLATORS: %(bridge)s - the bridge hostname - messages.pgettext('connection-info', '%(relay)s via %(bridge)s'), + // TRANSLATORS: %(entry)s - the entry relay hostname + messages.pgettext('connection-info', '%(relay)s via %(entry)s'), { relay: this.props.hostname, - bridge: this.props.bridgeHostname, + entry: this.props.entryHostname, }, ); } else { diff --git a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx b/gui/src/renderer/components/ConnectionPanelDisclosure.tsx index ad7c092f9c..d0727ddb6f 100644 --- a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx +++ b/gui/src/renderer/components/ConnectionPanelDisclosure.tsx @@ -6,13 +6,15 @@ import ImageView from './ImageView'; const Container = styled.div({ display: 'flex', alignItems: 'center', + width: '100%', }); -const Caption = styled.span((props: { open: boolean }) => ({ +const Caption = styled.span({}, (props: { open: boolean }) => ({ fontFamily: 'Open Sans', fontSize: '15px', fontWeight: 600, lineHeight: '20px', + minWidth: '0px', color: props.open ? colors.white : colors.white40, [Container + ':hover &']: { color: colors.white, @@ -28,7 +30,7 @@ const Chevron = styled(ImageView)({ interface IProps { pointsUp: boolean; onToggle?: () => void; - children: React.ReactText; + children: React.ReactNode; className?: string; } diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx index 6389784ecd..855efdc066 100644 --- a/gui/src/renderer/components/LocationList.tsx +++ b/gui/src/renderer/components/LocationList.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; import { colors } from '../../config.json'; import { @@ -7,8 +8,12 @@ import { RelayLocation, relayLocationComponents, } from '../../shared/daemon-rpc-types'; -import { relayLocations } from '../../shared/gettext'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; +import { messages, relayLocations } from '../../shared/gettext'; +import { + IRelayLocationRedux, + IRelayLocationCityRedux, + IRelayLocationRelayRedux, +} from '../redux/settings/reducers'; import * as Cell from './cell'; import LocationRow from './LocationRow'; @@ -257,35 +262,82 @@ export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T> }; } +export enum DisabledReason { + entry, + exit, + inactive, +} + interface IRelayLocationsProps { source: IRelayLocationRedux[]; + locale: string; selectedLocation?: RelayLocation; selectedElementRef?: React.Ref<React.ReactInstance>; expandedItems?: RelayLocation[]; + disabledLocation?: { location: RelayLocation; reason: DisabledReason }; onSelect?: (location: RelayLocation) => void; onExpand?: (location: RelayLocation, expand: boolean) => void; onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; onTransitionEnd?: () => void; } +interface Relay extends IRelayLocationRelayRedux { + label: string; + disabled: boolean; +} + +interface City extends Omit<IRelayLocationCityRedux, 'relays'> { + label: string; + active: boolean; + disabled: boolean; + relays: Array<Relay>; +} + +interface Country extends Omit<IRelayLocationRedux, 'cities'> { + label: string; + active: boolean; + disabled: boolean; + cities: Array<City>; +} + +type CountryList = Array<Country>; + +interface IRelayLocationsState { + countries: CountryList; +} + interface ICommonCellProps { location: RelayLocation; selected: boolean; ref?: React.Ref<HTMLDivElement>; } -export class RelayLocations extends React.PureComponent<IRelayLocationsProps> { +export class RelayLocations extends React.PureComponent< + IRelayLocationsProps, + IRelayLocationsState +> { + public state = { + countries: this.prepareRelaysForPresentation(this.props.source), + }; + + public componentDidUpdate(prevProps: IRelayLocationsProps) { + if (this.props.source !== prevProps.source) { + this.setState({ countries: this.prepareRelaysForPresentation(this.props.source) }); + } + } + public render() { return ( <> - {this.props.source.map((relayCountry) => { + {this.state.countries.map((relayCountry) => { const countryLocation: RelayLocation = { country: relayCountry.code }; return ( <LocationRow key={getLocationKey(countryLocation)} - name={relayLocations.gettext(relayCountry.name)} - active={relayCountry.hasActiveRelays} + name={relayCountry.label} + active={relayCountry.active} + disabled={relayCountry.disabled} expanded={this.isExpanded(countryLocation)} onSelect={this.handleSelection} onExpand={this.handleExpand} @@ -300,8 +352,9 @@ export class RelayLocations extends React.PureComponent<IRelayLocationsProps> { return ( <LocationRow key={getLocationKey(cityLocation)} - name={relayLocations.gettext(relayCity.name)} - active={relayCity.hasActiveRelays} + name={relayCity.label} + active={relayCity.active} + disabled={relayCity.disabled} expanded={this.isExpanded(cityLocation)} onSelect={this.handleSelection} onExpand={this.handleExpand} @@ -316,8 +369,9 @@ export class RelayLocations extends React.PureComponent<IRelayLocationsProps> { return ( <LocationRow key={getLocationKey(relayLocation)} - name={relay.hostname} + name={relay.label} active={relay.active} + disabled={relay.disabled} onSelect={this.handleSelection} {...this.getCommonCellProps(relayLocation)} /> @@ -333,6 +387,174 @@ export class RelayLocations extends React.PureComponent<IRelayLocationsProps> { ); } + private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): CountryList { + return relayList + .map((country) => { + const countryDisabled = this.isCountryDisabled(country, country.code); + const countryLocation = { country: country.code }; + + return { + ...country, + label: this.formatRowName(country.name, countryLocation, countryDisabled), + active: countryDisabled !== DisabledReason.inactive, + disabled: countryDisabled !== undefined, + cities: country.cities + .map((city) => { + const cityDisabled = + countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]); + const cityLocation: RelayLocation = { city: [country.code, city.code] }; + + return { + ...city, + label: this.formatRowName(city.name, cityLocation, cityDisabled), + active: cityDisabled !== DisabledReason.inactive, + disabled: cityDisabled !== undefined, + relays: city.relays + .map((relay) => { + const relayDisabled = + countryDisabled ?? + cityDisabled ?? + this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]); + const relayLocation: RelayLocation = { + hostname: [country.code, city.code, relay.hostname], + }; + + return { + ...relay, + label: this.formatRowName(relay.hostname, relayLocation, relayDisabled), + disabled: relayDisabled !== undefined, + }; + }) + .sort((a, b) => + a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }), + ), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name, this.props.locale)), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name, this.props.locale)); + } + + private formatRowName( + name: string, + location: RelayLocation, + disabledReason?: DisabledReason, + ): string { + const translatedName = 'hostname' in location ? name : relayLocations.gettext(name); + const disabledLocation = this.props.disabledLocation; + const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location); + + let info: string | undefined; + if ( + disabledReason === DisabledReason.entry || + (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry) + ) { + info = messages.pgettext('select-location-view', 'Entry'); + } else if ( + disabledReason === DisabledReason.exit || + (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit) + ) { + info = messages.pgettext('select-location-view', 'Exit'); + } + + return info !== undefined + ? sprintf( + // TRANSLATORS: This is used for appending information about a location. + // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(location)s - Translated location name + // TRANSLATORS: %(info)s - Information about the location + messages.pgettext('select-location-view', '%(location)s (%(info)s)'), + { + location: translatedName, + info, + }, + ) + : translatedName; + } + + private isRelayDisabled( + relay: IRelayLocationRelayRedux, + location: [string, string, string], + ): DisabledReason | undefined { + if (!relay.active) { + return DisabledReason.inactive; + } else if ( + this.props.disabledLocation && + compareRelayLocation({ hostname: location }, this.props.disabledLocation.location) + ) { + return this.props.disabledLocation.reason; + } else { + return undefined; + } + } + + private isCityDisabled( + city: IRelayLocationCityRedux, + location: [string, string], + ): DisabledReason | undefined { + const relaysDisabled = city.relays.map((relay) => + this.isRelayDisabled(relay, [...location, relay.hostname]), + ); + if (relaysDisabled.every((status) => status === DisabledReason.inactive)) { + return DisabledReason.inactive; + } + + const disabledDueToSelection = relaysDisabled.find( + (status) => status === DisabledReason.entry || status === DisabledReason.exit, + ); + + if ( + relaysDisabled.every((status) => status !== undefined) && + disabledDueToSelection !== undefined + ) { + return disabledDueToSelection; + } + + if ( + this.props.disabledLocation && + compareRelayLocation({ city: location }, this.props.disabledLocation.location) && + city.relays.filter((relay) => relay.active).length <= 1 + ) { + return this.props.disabledLocation.reason; + } + + return undefined; + } + + private isCountryDisabled( + country: IRelayLocationRedux, + location: string, + ): DisabledReason | undefined { + const citiesDisabled = country.cities.map((city) => + this.isCityDisabled(city, [location, city.code]), + ); + if (citiesDisabled.every((status) => status === DisabledReason.inactive)) { + return DisabledReason.inactive; + } + + const disabledDueToSelection = citiesDisabled.find( + (status) => status === DisabledReason.entry || status === DisabledReason.exit, + ); + if ( + citiesDisabled.every((status) => status !== undefined) && + disabledDueToSelection !== undefined + ) { + return disabledDueToSelection; + } + + if ( + this.props.disabledLocation && + compareRelayLocation({ country: location }, this.props.disabledLocation.location) && + country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1 + ) { + return this.props.disabledLocation.reason; + } + + return undefined; + } + private isExpanded(relayLocation: RelayLocation) { return (this.props.expandedItems || []).some((location) => compareRelayLocation(location, relayLocation), diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/LocationRow.tsx index cf7bc5942c..980ffaae3e 100644 --- a/gui/src/renderer/components/LocationRow.tsx +++ b/gui/src/renderer/components/LocationRow.tsx @@ -67,6 +67,7 @@ const Label = styled(Cell.Label)({ interface IProps { name: string; active: boolean; + disabled: boolean; location: RelayLocation; selected: boolean; expanded?: boolean; @@ -105,13 +106,13 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { <Container ref={ref} selected={props.selected} - disabled={!props.active} + disabled={props.disabled} location={props.location}> <Button ref={buttonRef} onClick={handleClick} location={props.location} - disabled={!props.active}> + disabled={props.disabled}> <RelayStatusIndicator active={props.active} selected={props.selected} /> <Label>{props.name}</Label> </Button> @@ -149,6 +150,7 @@ function compareProps(oldProps: IProps, nextProps: IProps): boolean { React.Children.count(oldProps.children) === React.Children.count(nextProps.children) && oldProps.name === nextProps.name && oldProps.active === nextProps.active && + oldProps.disabled === nextProps.disabled && oldProps.selected === nextProps.selected && oldProps.expanded === nextProps.expanded && oldProps.onSelect === nextProps.onSelect && diff --git a/gui/src/renderer/components/ExitLocations.tsx b/gui/src/renderer/components/Locations.tsx index e4174a0e7c..901d27c9ec 100644 --- a/gui/src/renderer/components/ExitLocations.tsx +++ b/gui/src/renderer/components/Locations.tsx @@ -2,25 +2,25 @@ import * as React from 'react'; import { RelayLocation } from '../../shared/daemon-rpc-types'; import { IRelayLocationRedux } from '../redux/settings/reducers'; import LocationList, { + DisabledReason, LocationSelection, LocationSelectionType, RelayLocations, } from './LocationList'; -interface IExitLocationsProps { +interface ILocationsProps { source: IRelayLocationRedux[]; + locale: string; defaultExpandedLocations?: RelayLocation[]; selectedValue?: RelayLocation; + disabledLocation?: { location: RelayLocation; reason: DisabledReason }; selectedElementRef?: React.Ref<React.ReactInstance>; onSelect?: (value: LocationSelection<never>) => void; onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; onTransitionEnd?: () => void; } -const ExitLocations = React.forwardRef(function ExitLocationsT( - props: IExitLocationsProps, - ref: React.Ref<LocationList<never>>, -) { +function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>) { const selectedValue: LocationSelection<never> | undefined = props.selectedValue ? { type: LocationSelectionType.relay, value: props.selectedValue } : undefined; @@ -34,11 +34,14 @@ const ExitLocations = React.forwardRef(function ExitLocationsT( onSelect={props.onSelect}> <RelayLocations source={props.source} + locale={props.locale} + disabledLocation={props.disabledLocation} onWillExpand={props.onWillExpand} onTransitionEnd={props.onTransitionEnd} /> </LocationList> ); -}); +} -export default ExitLocations; +export const ExitLocations = React.forwardRef(Locations); +export const EntryLocations = React.forwardRef(Locations); diff --git a/gui/src/renderer/components/OpenVPNSettings.tsx b/gui/src/renderer/components/OpenVPNSettings.tsx index 72e672b681..1696f45052 100644 --- a/gui/src/renderer/components/OpenVPNSettings.tsx +++ b/gui/src/renderer/components/OpenVPNSettings.tsx @@ -3,10 +3,11 @@ import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; import { BridgeState, RelayProtocol } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; +import * as AppButton from './AppButton'; import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; import * as Cell from './cell'; import { Layout, SettingsContainer } from './Layout'; -import { ModalContainer } from './Modal'; +import { ModalAlert, ModalAlertType, ModalContainer } from './Modal'; import { BackBarItem, NavigationBar, @@ -17,6 +18,7 @@ import { } from './NavigationBar'; import Selector, { ISelectorItem } from './cell/Selector'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; +import { formatMarkdown } from '../markdown-formatter'; const MIN_MSSFIX_VALUE = 1000; const MAX_MSSFIX_VALUE = 1450; @@ -43,7 +45,12 @@ export const StyledInputFrame = styled(Cell.InputFrame)({ flex: 0, }); +export const StyledSelectorForFooter = (styled(Selector)({ + marginBottom: 0, +}) as unknown) as new <T>() => Selector<T>; + interface IProps { + tunnelProtocolIsOpenVpn: boolean; openvpn: { protocol?: RelayProtocol; port?: number; @@ -56,10 +63,15 @@ interface IProps { onClose: () => void; } -export default class OpenVpnSettings extends React.Component<IProps> { +interface IState { + showBridgeStateConfirmationDialog: boolean; +} + +export default class OpenVpnSettings extends React.Component<IProps, IState> { + public state = { showBridgeStateConfirmationDialog: false }; + private portItems: { [key in RelayProtocol]: Array<ISelectorItem<OptionalPort>> }; private protocolItems: Array<ISelectorItem<OptionalRelayProtocol>>; - private bridgeStateItems: Array<ISelectorItem<BridgeState>>; constructor(props: IProps) { super(props); @@ -88,21 +100,6 @@ export default class OpenVpnSettings extends React.Component<IProps> { value: 'udp', }, ]; - - this.bridgeStateItems = [ - { - label: messages.gettext('Automatic'), - value: 'auto', - }, - { - label: messages.gettext('On'), - value: 'on', - }, - { - label: messages.gettext('Off'), - value: 'off', - }, - ]; } public render() { @@ -164,15 +161,39 @@ export default class OpenVpnSettings extends React.Component<IProps> { </AriaInputGroup> <AriaInputGroup> - <Selector - title={ - // TRANSLATORS: The title for the shadowsocks bridge selector section. - messages.pgettext('openvpn-settings-view', 'Bridge mode') - } - values={this.bridgeStateItems} - value={this.props.bridgeState} - onSelect={this.onSelectBridgeState} - /> + <StyledSelectorContainer> + <StyledSelectorForFooter + title={ + // TRANSLATORS: The title for the shadowsocks bridge selector section. + messages.pgettext('openvpn-settings-view', 'Bridge mode') + } + values={this.bridgeStateItems(this.props.tunnelProtocolIsOpenVpn)} + value={this.props.bridgeState} + onSelect={this.onSelectBridgeState} + /> + </StyledSelectorContainer> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {this.props.tunnelProtocolIsOpenVpn + ? // This line is here to prevent prettier from moving up the next line. + // TRANSLATORS: This is used as a description for the bridge mode + // TRANSLATORS: setting. + messages.pgettext( + 'openvpn-settings-view', + 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an OpenVPN server. Obfuscation is added to make fingerprinting harder.', + ) + : formatMarkdown( + // TRANSLATORS: This is used to instruct users how to make the bridge + // TRANSLATORS: mode setting available. + messages.pgettext( + 'openvpn-settings-view', + 'To activate Bridge mode, go back and change **Tunnel protocol** to **OpenVPN**.', + ), + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> </AriaInputGroup> <AriaInputGroup> @@ -222,10 +243,30 @@ export default class OpenVpnSettings extends React.Component<IProps> { </NavigationContainer> </SettingsContainer> </Layout> + + {this.state.showBridgeStateConfirmationDialog && this.renderBridgeStateConfirmation()} </ModalContainer> ); } + private bridgeStateItems(onAvailable: boolean): Array<ISelectorItem<BridgeState>> { + return [ + { + label: messages.gettext('Automatic'), + value: 'auto', + }, + { + label: messages.gettext('On'), + value: 'on', + disabled: !onAvailable, + }, + { + label: messages.gettext('Off'), + value: 'off', + }, + ]; + } + private onSelectOpenvpnProtocol = (protocol?: RelayProtocol) => { this.props.setOpenVpnRelayProtocolAndPort(protocol); }; @@ -234,10 +275,6 @@ export default class OpenVpnSettings extends React.Component<IProps> { this.props.setOpenVpnRelayProtocolAndPort(this.props.openvpn.protocol, port); }; - private onSelectBridgeState = (bridgeState: BridgeState) => { - this.props.setBridgeState(bridgeState); - }; - private onMssfixSubmit = (value: string) => { const parsedValue = value === '' ? undefined : parseInt(value, 10); if (OpenVpnSettings.mssfixIsValid(value)) { @@ -256,4 +293,38 @@ export default class OpenVpnSettings extends React.Component<IProps> { (parsedMssFix >= MIN_MSSFIX_VALUE && parsedMssFix <= MAX_MSSFIX_VALUE) ); } + + private renderBridgeStateConfirmation = () => { + return ( + <ModalAlert + type={ModalAlertType.info} + message={messages.gettext('This setting increases latency. Use only if needed.')} + buttons={[ + <AppButton.RedButton key="confirm" onClick={this.confirmBridgeState}> + {messages.gettext('Enable anyway')} + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={this.hideBridgeStateConfirmationDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + close={this.hideBridgeStateConfirmationDialog}></ModalAlert> + ); + }; + + private onSelectBridgeState = (newValue: BridgeState) => { + if (newValue === 'on') { + this.setState({ showBridgeStateConfirmationDialog: true }); + } else { + this.props.setBridgeState(newValue); + } + }; + + private hideBridgeStateConfirmationDialog = () => { + this.setState({ showBridgeStateConfirmationDialog: false }); + }; + + private confirmBridgeState = () => { + this.setState({ showBridgeStateConfirmationDialog: false }); + this.props.setBridgeState('on'); + }; } diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx index 528e8129ef..b6698fcbc7 100644 --- a/gui/src/renderer/components/SelectLocation.tsx +++ b/gui/src/renderer/components/SelectLocation.tsx @@ -1,16 +1,19 @@ import React from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../config.json'; -import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; +import { LiftedConstraint, RelayLocation, TunnelProtocol } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { IRelayLocationRedux } from '../redux/settings/reducers'; -import { LocationScope } from '../redux/userinterface/reducers'; import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; import { CustomScrollbarsRef } from './CustomScrollbars'; -import ExitLocations from './ExitLocations'; +import { EntryLocations, ExitLocations } from './Locations'; import ImageView from './ImageView'; import { Layout } from './Layout'; -import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; +import LocationList, { + DisabledReason, + LocationSelection, + LocationSelectionType, +} from './LocationList'; import { CloseBarItem, NavigationBar, @@ -37,25 +40,33 @@ import { import { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; interface IProps { - locationScope: LocationScope; + locale: string; selectedExitLocation?: RelayLocation; + selectedEntryLocation?: RelayLocation; selectedBridgeLocation?: LiftedConstraint<RelayLocation>; relayLocations: IRelayLocationRedux[]; bridgeLocations: IRelayLocationRedux[]; - allowBridgeSelection: boolean; + allowEntrySelection: boolean; + tunnelProtocol: LiftedConstraint<TunnelProtocol>; providers: string[]; onClose: () => void; onViewFilterByProvider: () => void; - onChangeLocationScope: (location: LocationScope) => void; onSelectExitLocation: (location: RelayLocation) => void; + onSelectEntryLocation: (location: RelayLocation) => void; onSelectBridgeLocation: (location: RelayLocation) => void; onSelectClosestToExit: () => void; onClearProviders: () => void; } +enum LocationScope { + entry = 0, + exit, +} + interface IState { showFilterMenu: boolean; headingHeight: number; + locationScope: LocationScope; } interface ISelectLocationSnapshot { @@ -64,17 +75,19 @@ interface ISelectLocationSnapshot { } export default class SelectLocation extends React.Component<IProps, IState> { - public state = { showFilterMenu: false, headingHeight: 0 }; + public state = { showFilterMenu: false, headingHeight: 0, locationScope: LocationScope.exit }; private scrollView = React.createRef<CustomScrollbarsRef>(); private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>(); private selectedExitLocationRef = React.createRef<React.ReactInstance>(); + private selectedEntryLocationRef = React.createRef<React.ReactInstance>(); private selectedBridgeLocationRef = React.createRef<React.ReactInstance>(); private exitLocationList = React.createRef<LocationList<never>>(); + private entryLocationList = React.createRef<LocationList<never>>(); private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>(); - private snapshotByScope: { [index: number]: ISelectLocationSnapshot } = {}; + private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {}; private filterButtonRef = React.createRef<HTMLDivElement>(); private headerRef = React.createRef<HTMLHeadingElement>(); @@ -87,25 +100,25 @@ export default class SelectLocation extends React.Component<IProps, IState> { } public componentDidUpdate( - prevProps: IProps, - _prevState: unknown, + _prevProps: IProps, + prevState: IState, snapshot?: ISelectLocationSnapshot, ) { - if (this.props.locationScope !== prevProps.locationScope) { - this.restoreScrollPosition(this.props.locationScope); + if (this.state.locationScope !== prevState.locationScope) { + this.restoreScrollPosition(this.state.locationScope); if (snapshot) { - this.snapshotByScope[prevProps.locationScope] = snapshot; + this.snapshotByScope[prevState.locationScope] = snapshot; } } } - public getSnapshotBeforeUpdate(prevProps: IProps): ISelectLocationSnapshot | undefined { + public getSnapshotBeforeUpdate( + prevProps: IProps, + prevState: IState, + ): ISelectLocationSnapshot | undefined { const scrollView = this.scrollView.current; - const locationList = - prevProps.locationScope === LocationScope.relay - ? this.exitLocationList.current - : this.bridgeLocationList.current; + const locationList = this.getLocationListRef(prevProps, prevState); if (scrollView && locationList) { return { @@ -164,13 +177,7 @@ export default class SelectLocation extends React.Component<IProps, IState> { messages.pgettext('select-location-view', 'Select location') } </HeaderTitle> - <HeaderSubTitle> - {this.props.allowBridgeSelection && - messages.pgettext( - 'select-location-view', - 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).', - )} - </HeaderSubTitle> + {this.renderHeaderSubtitle()} </StyledSettingsHeader> {this.props.providers.length > 0 && ( @@ -200,45 +207,21 @@ export default class SelectLocation extends React.Component<IProps, IState> { </StyledProvidersCount> </StyledProviderCountRow> )} - {this.props.allowBridgeSelection && ( + {this.props.allowEntrySelection && ( <StyledScopeBar - defaultSelectedIndex={this.props.locationScope} - onChange={this.props.onChangeLocationScope}> + defaultSelectedIndex={this.state.locationScope} + onChange={this.onChangeLocationScope}> <ScopeBarItem> - {messages.pgettext('select-location-nav', 'Entry')} + {messages.pgettext('select-location-view', 'Entry')} </ScopeBarItem> <ScopeBarItem> - {messages.pgettext('select-location-nav', 'Exit')} + {messages.pgettext('select-location-view', 'Exit')} </ScopeBarItem> </StyledScopeBar> )} </StyledNavigationBarAttachment> - <StyledContent> - {this.props.locationScope === LocationScope.relay ? ( - <ExitLocations - ref={this.exitLocationList} - source={this.props.relayLocations} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedExitLocation} - selectedElementRef={this.selectedExitLocationRef} - onSelect={this.onSelectExitLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ) : ( - <BridgeLocations - ref={this.bridgeLocationList} - source={this.props.bridgeLocations} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedBridgeLocation} - selectedElementRef={this.selectedBridgeLocationRef} - onSelect={this.onSelectBridgeLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - )} - </StyledContent> + <StyledContent>{this.renderLocationList()}</StyledContent> </SpacePreAllocationView> </NavigationScrollbars> </NavigationContainer> @@ -257,12 +240,118 @@ export default class SelectLocation extends React.Component<IProps, IState> { } } + private getLocationListRef(prevProps: IProps, prevState: IState) { + if (prevState.locationScope === LocationScope.exit) { + return this.exitLocationList.current; + } else if (prevProps.tunnelProtocol === 'wireguard') { + return this.entryLocationList.current; + } else { + return this.bridgeLocationList.current; + } + } + + private getSelectedLocationRef() { + if (this.state.locationScope === LocationScope.exit) { + return this.selectedExitLocationRef.current; + } else if (this.props.tunnelProtocol === 'wireguard') { + return this.selectedEntryLocationRef.current; + } else { + return this.selectedBridgeLocationRef.current; + } + } + + private renderHeaderSubtitle() { + if (this.props.allowEntrySelection) { + if (this.props.tunnelProtocol === 'openvpn') { + return ( + <HeaderSubTitle> + {messages.pgettext( + 'select-location-view', + 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).', + )} + </HeaderSubTitle> + ); + } else { + return ( + <HeaderSubTitle> + {messages.pgettext( + 'select-location-view', + 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).', + )} + </HeaderSubTitle> + ); + } + } else { + return null; + } + } + + private renderLocationList() { + if (this.state.locationScope === LocationScope.exit) { + const disabledLocation = this.props.selectedEntryLocation + ? { + location: this.props.selectedEntryLocation, + reason: DisabledReason.entry, + } + : undefined; + return ( + <ExitLocations + ref={this.exitLocationList} + source={this.props.relayLocations} + locale={this.props.locale} + defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} + selectedValue={this.props.selectedExitLocation} + selectedElementRef={this.selectedExitLocationRef} + disabledLocation={disabledLocation} + onSelect={this.onSelectExitLocation} + onWillExpand={this.onWillExpand} + onTransitionEnd={this.resetHeight} + /> + ); + } else if (this.props.tunnelProtocol === 'any' || this.props.tunnelProtocol === 'wireguard') { + const disabledLocation = this.props.selectedExitLocation + ? { + location: this.props.selectedExitLocation, + reason: DisabledReason.exit, + } + : undefined; + return ( + <EntryLocations + ref={this.entryLocationList} + source={this.props.relayLocations} + locale={this.props.locale} + defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} + selectedValue={this.props.selectedEntryLocation} + selectedElementRef={this.selectedEntryLocationRef} + disabledLocation={disabledLocation} + onSelect={this.onSelectEntryLocation} + onWillExpand={this.onWillExpand} + onTransitionEnd={this.resetHeight} + /> + ); + } else { + return ( + <BridgeLocations + ref={this.bridgeLocationList} + source={this.props.bridgeLocations} + locale={this.props.locale} + defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} + selectedValue={this.props.selectedBridgeLocation} + selectedElementRef={this.selectedBridgeLocationRef} + onSelect={this.onSelectBridgeLocation} + onWillExpand={this.onWillExpand} + onTransitionEnd={this.resetHeight} + /> + ); + } + } + private resetHeight = () => { this.spacePreAllocationViewRef.current?.reset(); }; private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined { - const snapshot = this.snapshotByScope[this.props.locationScope]; + const snapshot = this.snapshotByScope[this.state.locationScope]; if (snapshot) { return snapshot.expandedLocations; } else { @@ -278,10 +367,7 @@ export default class SelectLocation extends React.Component<IProps, IState> { } private scrollToSelectedCell() { - const ref = - this.props.locationScope === LocationScope.relay - ? this.selectedExitLocationRef.current - : this.selectedBridgeLocationRef.current; + const ref = this.getSelectedLocationRef(); const scrollView = this.scrollView.current; if (scrollView) { @@ -295,12 +381,20 @@ export default class SelectLocation extends React.Component<IProps, IState> { } } + private onChangeLocationScope = (locationScope: LocationScope) => { + this.setState({ locationScope }); + }; + private onSelectExitLocation = (location: LocationSelection<never>) => { if (location.type === LocationSelectionType.relay) { this.props.onSelectExitLocation(location.value); } }; + private onSelectEntryLocation = (location: LocationSelection<never>) => { + this.props.onSelectEntryLocation(location.value); + }; + private onSelectBridgeLocation = (location: LocationSelection<SpecialBridgeLocationType>) => { if (location.type === LocationSelectionType.relay) { this.props.onSelectBridgeLocation(location.value); diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx index 8bd379d7d4..d41d647266 100644 --- a/gui/src/renderer/components/WireguardSettings.tsx +++ b/gui/src/renderer/components/WireguardSettings.tsx @@ -3,9 +3,11 @@ import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; import { IpVersion } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; +import * as AppButton from './AppButton'; import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; import * as Cell from './cell'; import { Layout, SettingsContainer } from './Layout'; +import { ModalAlert, ModalAlertType, ModalContainer } from './Modal'; import { BackBarItem, NavigationBar, @@ -16,6 +18,7 @@ import { } from './NavigationBar'; import Selector, { ISelectorItem } from './cell/Selector'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; +import Switch from './Switch'; const MIN_WIREGUARD_MTU_VALUE = 1280; const MAX_WIREGUARD_MTU_VALUE = 1420; @@ -47,13 +50,24 @@ export const StyledInputFrame = styled(Cell.InputFrame)({ interface IProps { wireguard: { port?: number; ipVersion?: IpVersion }; wireguardMtu?: number; + wireguardMultihop: boolean; setWireguardMtu: (value: number | undefined) => void; - setWireguardRelayPortAndIpVersion: (port?: number, ipVersion?: IpVersion) => void; + setWireguardMultihop: (value: boolean) => void; + setWireguardPort: (port?: number) => void; + setWireguardIpVersion: (ipVersion?: IpVersion) => void; onViewWireguardKeys: () => void; onClose: () => void; } -export default class WireguardSettings extends React.Component<IProps> { +interface IState { + showMultihopConfirmationDialog: boolean; +} + +export default class WireguardSettings extends React.Component<IProps, IState> { + public state = { showMultihopConfirmationDialog: false }; + + private multihopRef = React.createRef<Switch>(); + private wireguardPortItems: Array<ISelectorItem<OptionalPort>>; private wireguardIpVersionItems: Array<ISelectorItem<OptionalIpVersion>>; @@ -87,149 +101,178 @@ export default class WireguardSettings extends React.Component<IProps> { public render() { return ( - <Layout> - <SettingsContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <BackBarItem action={this.props.onClose}> - { - // TRANSLATORS: Back button in navigation bar - messages.pgettext('navigation-bar', 'Advanced') - } - </BackBarItem> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('wireguard-settings-nav', 'WireGuard settings') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> + <ModalContainer> + <Layout> + <SettingsContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <BackBarItem action={this.props.onClose}> + { + // TRANSLATORS: Back button in navigation bar + messages.pgettext('navigation-bar', 'Advanced') + } + </BackBarItem> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('wireguard-settings-nav', 'WireGuard settings') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> - <StyledNavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('wireguard-settings-view', 'WireGuard settings')} - </HeaderTitle> - </SettingsHeader> + <StyledNavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('wireguard-settings-view', 'WireGuard settings')} + </HeaderTitle> + </SettingsHeader> - <AriaInputGroup> - <StyledSelectorContainer> - <StyledSelectorForFooter - // TRANSLATORS: The title for the WireGuard port selector. - title={messages.pgettext('wireguard-settings-view', 'Port')} - values={this.wireguardPortItems} - value={this.props.wireguard.port} - onSelect={this.onSelectWireguardPort} - /> - </StyledSelectorContainer> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - { - // TRANSLATORS: The hint displayed below the WireGuard port selector. - messages.pgettext( - 'wireguard-settings-view', - 'The automatic setting will randomly choose from a wide range of ports.', - ) - } - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <StyledSelectorContainer> - <StyledSelectorForFooter - // TRANSLATORS: The title for the WireGuard IP version selector. - title={messages.pgettext('wireguard-settings-view', 'IP version')} - values={this.wireguardIpVersionItems} - value={this.props.wireguard.ipVersion} - onSelect={this.onSelectWireguardIpVersion} - /> - </StyledSelectorContainer> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - { - // TRANSLATORS: The hint displayed below the WireGuard IP version selector. - messages.pgettext( - 'wireguard-settings-view', - 'This allows access to WireGuard for devices that only support IPv6.', - ) - } - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <Cell.CellButtonGroup> - <Cell.CellButton onClick={this.props.onViewWireguardKeys}> - <Cell.Label> - {messages.pgettext('wireguard-settings-view', 'WireGuard key')} - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - </Cell.CellButtonGroup> + <AriaInputGroup> + <StyledSelectorContainer> + <StyledSelectorForFooter + // TRANSLATORS: The title for the WireGuard port selector. + title={messages.pgettext('wireguard-settings-view', 'Port')} + values={this.wireguardPortItems} + value={this.props.wireguard.port} + onSelect={this.props.setWireguardPort} + /> + </StyledSelectorContainer> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + { + // TRANSLATORS: The hint displayed below the WireGuard port selector. + messages.pgettext( + 'wireguard-settings-view', + 'The automatic setting will randomly choose from a wide range of ports.', + ) + } + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('wireguard-settings-view', 'MTU')} - </Cell.InputLabel> - </AriaLabel> - <StyledInputFrame> + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + { + // TRANSLATORS: The label next to the multihop settings toggle. + messages.pgettext('advanced-settings-view', 'Enable multihop') + } + </Cell.InputLabel> + </AriaLabel> <AriaInput> - <Cell.AutoSizingTextInput - value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''} - inputMode={'numeric'} - maxLength={4} - placeholder={messages.gettext('Default')} - onSubmitValue={this.onWireguardMtuSubmit} - validateValue={WireguardSettings.wireguarMtuIsValid} - submitOnBlur={true} - modifyValue={WireguardSettings.removeNonNumericCharacters} + <Cell.Switch + ref={this.multihopRef} + isOn={this.props.wireguardMultihop} + onChange={this.setWireguardMultihop} /> </AriaInput> - </StyledInputFrame> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {sprintf( - // TRANSLATORS: The hint displayed below the WireGuard MTU input field. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value - // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value - messages.pgettext( - 'wireguard-settings-view', - 'Set WireGuard MTU value. Valid range: %(min)d - %(max)d.', - ), + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> { - min: MIN_WIREGUARD_MTU_VALUE, - max: MAX_WIREGUARD_MTU_VALUE, - }, - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - </StyledNavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - ); - } + // TRANSLATORS: Description for multihop settings toggle. + messages.pgettext( + 'advanced-settings-view', + 'Increases anonymity by routing your traffic into one WireGuard server and out another, making it harder to trace.', + ) + } + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> - private onSelectWireguardPort = (port?: number) => { - this.props.setWireguardRelayPortAndIpVersion(port, this.props.wireguard.ipVersion); - }; + <AriaInputGroup> + <StyledSelectorContainer> + <StyledSelectorForFooter + // TRANSLATORS: The title for the WireGuard IP version selector. + title={messages.pgettext('wireguard-settings-view', 'IP version')} + values={this.wireguardIpVersionItems} + value={this.props.wireguard.ipVersion} + onSelect={this.props.setWireguardIpVersion} + /> + </StyledSelectorContainer> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + { + // TRANSLATORS: The hint displayed below the WireGuard IP version selector. + messages.pgettext( + 'wireguard-settings-view', + 'This allows access to WireGuard for devices that only support IPv6.', + ) + } + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> - private onSelectWireguardIpVersion = (ipVersion?: IpVersion) => { - this.props.setWireguardRelayPortAndIpVersion(this.props.wireguard.port, ipVersion); - }; + <Cell.CellButtonGroup> + <Cell.CellButton onClick={this.props.onViewWireguardKeys}> + <Cell.Label> + {messages.pgettext('wireguard-settings-view', 'WireGuard key')} + </Cell.Label> + <Cell.Icon height={12} width={7} source="icon-chevron" /> + </Cell.CellButton> + </Cell.CellButtonGroup> + + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('wireguard-settings-view', 'MTU')} + </Cell.InputLabel> + </AriaLabel> + <StyledInputFrame> + <AriaInput> + <Cell.AutoSizingTextInput + value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''} + inputMode={'numeric'} + maxLength={4} + placeholder={messages.gettext('Default')} + onSubmitValue={this.onWireguardMtuSubmit} + validateValue={WireguardSettings.wireguarMtuIsValid} + submitOnBlur={true} + modifyValue={WireguardSettings.removeNonNumericCharacters} + /> + </AriaInput> + </StyledInputFrame> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {sprintf( + // TRANSLATORS: The hint displayed below the WireGuard MTU input field. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value + // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value + messages.pgettext( + 'wireguard-settings-view', + 'Set WireGuard MTU value. Valid range: %(min)d - %(max)d.', + ), + { + min: MIN_WIREGUARD_MTU_VALUE, + max: MAX_WIREGUARD_MTU_VALUE, + }, + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + </StyledNavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + + {this.state.showMultihopConfirmationDialog && this.renderMultihopConfirmation()} + </ModalContainer> + ); + } private static removeNonNumericCharacters(value: string) { return value.replace(/[^0-9]/g, ''); @@ -249,4 +292,42 @@ export default class WireguardSettings extends React.Component<IProps> { (parsedMtu >= MIN_WIREGUARD_MTU_VALUE && parsedMtu <= MAX_WIREGUARD_MTU_VALUE) ); } + + private renderMultihopConfirmation = () => { + return ( + <ModalAlert + type={ModalAlertType.info} + message={ + // TRANSLATORS: Warning text in a dialog that is displayed after a setting is toggled. + messages.gettext('This setting increases latency. Use only if needed.') + } + buttons={[ + <AppButton.RedButton key="confirm" onClick={this.confirmWireguardMultihop}> + {messages.gettext('Enable anyway')} + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={this.hideWireguardMultihopConfirmationDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + close={this.hideWireguardMultihopConfirmationDialog}></ModalAlert> + ); + }; + + private setWireguardMultihop = (newValue: boolean) => { + if (newValue) { + this.setState({ showMultihopConfirmationDialog: true }); + } else { + this.props.setWireguardMultihop(false); + } + }; + + private hideWireguardMultihopConfirmationDialog = () => { + this.setState({ showMultihopConfirmationDialog: false }); + this.multihopRef.current?.setOn(this.props.wireguardMultihop); + }; + + private confirmWireguardMultihop = () => { + this.setState({ showMultihopConfirmationDialog: false }); + this.props.setWireguardMultihop(true); + }; } diff --git a/gui/src/renderer/containers/ConnectionPanelContainer.tsx b/gui/src/renderer/containers/ConnectionPanelContainer.tsx index b3b67e9438..bf8ac77660 100644 --- a/gui/src/renderer/containers/ConnectionPanelContainer.tsx +++ b/gui/src/renderer/containers/ConnectionPanelContainer.tsx @@ -19,6 +19,22 @@ function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): IInAdd }; } +function tunnelEndpointToEntryLocationInAddress( + tunnelEndpoint: ITunnelEndpoint, +): IInAddress | undefined { + if (!tunnelEndpoint.entryEndpoint) { + return undefined; + } + + const socketAddr = parseSocketAddress(tunnelEndpoint.entryEndpoint.address); + return { + ip: socketAddr.host, + port: socketAddr.port, + protocol: tunnelEndpoint.entryEndpoint.transportProtocol, + tunnelType: tunnelEndpoint.tunnelType, + }; +} + function tunnelEndpointToBridgeData(endpoint: ITunnelEndpoint): IBridgeData | undefined { if (!endpoint.proxy) { return undefined; @@ -46,6 +62,11 @@ const mapStateToProps = (state: IReduxState) => { ? tunnelEndpointToRelayInAddress(status.details.endpoint) : undefined; + const entryLocationInAddress: IInAddress | undefined = + (status.state === 'connecting' || status.state === 'connected') && status.details + ? tunnelEndpointToEntryLocationInAddress(status.details.endpoint) + : undefined; + const bridgeInfo: IBridgeData | undefined = (status.state === 'connecting' || status.state === 'connected') && status.details ? tunnelEndpointToBridgeData(status.details.endpoint) @@ -55,7 +76,9 @@ const mapStateToProps = (state: IReduxState) => { isOpen: state.userInterface.connectionPanelVisible, hostname: state.connection.hostname, bridgeHostname: state.connection.bridgeHostname, + entryHostname: state.connection.entryHostname, inAddress, + entryLocationInAddress, bridgeInfo, outAddress, }; diff --git a/gui/src/renderer/containers/OpenVPNSettingsPage.tsx b/gui/src/renderer/containers/OpenVPNSettingsPage.tsx index 9212ca68ac..9885afe971 100644 --- a/gui/src/renderer/containers/OpenVPNSettingsPage.tsx +++ b/gui/src/renderer/containers/OpenVPNSettingsPage.tsx @@ -13,12 +13,26 @@ const mapStateToProps = (state: IReduxState) => { const protocolAndPort = mapRelaySettingsToProtocolAndPort(state.settings.relaySettings); return { + tunnelProtocolIsOpenVpn: mapRelaySettingsToProtocol(state.settings.relaySettings) === 'openvpn', mssfix: state.settings.openVpn.mssfix, bridgeState: state.settings.bridgeState, ...protocolAndPort, }; }; +const mapRelaySettingsToProtocol = (relaySettings: RelaySettingsRedux) => { + if ('normal' in relaySettings) { + const { tunnelProtocol } = relaySettings.normal; + return tunnelProtocol === 'any' ? undefined : tunnelProtocol; + // since the GUI doesn't display custom settings, just display the default ones. + // If the user sets any settings, then those will be applied. + } else if ('customTunnelEndpoint' in relaySettings) { + return undefined; + } else { + throw new Error('Unknown type of relay settings.'); + } +}; + const mapRelaySettingsToProtocolAndPort = (relaySettings: RelaySettingsRedux) => { if ('normal' in relaySettings) { const { openvpn } = relaySettings.normal; diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx index cd455a01f6..1effeac473 100644 --- a/gui/src/renderer/containers/SelectLocationPage.tsx +++ b/gui/src/renderer/containers/SelectLocationPage.tsx @@ -1,21 +1,21 @@ import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import BridgeSettingsBuilder from '../../shared/bridge-settings-builder'; import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; import log from '../../shared/logging'; import RelaySettingsBuilder from '../../shared/relay-settings-builder'; import SelectLocation from '../components/SelectLocation'; import withAppContext, { IAppContext } from '../context'; +import { createWireguardRelayUpdater } from '../lib/constraint-updater'; import { IHistoryProps, withHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { IRelayLocationRedux } from '../redux/settings/reducers'; import { IReduxState, ReduxDispatch } from '../redux/store'; -import userInterfaceActions from '../redux/userinterface/actions'; -import { LocationScope } from '../redux/userinterface/reducers'; -const mapStateToProps = (state: IReduxState) => { +const mapStateToProps = (state: IReduxState, props: IHistoryProps & IAppContext) => { let selectedExitLocation: RelayLocation | undefined; + let selectedEntryLocation: RelayLocation | undefined; let selectedBridgeLocation: LiftedConstraint<RelayLocation> | undefined; + let multihopEnabled = false; if ('normal' in state.settings.relaySettings) { const exitLocation = state.settings.relaySettings.normal.location; @@ -24,37 +24,58 @@ const mapStateToProps = (state: IReduxState) => { } } - if ('normal' in state.settings.bridgeSettings) { + const relaySettings = state.settings.relaySettings; + const tunnelProtocol = 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any'; + + if (tunnelProtocol === 'openvpn' && 'normal' in state.settings.bridgeSettings) { selectedBridgeLocation = state.settings.bridgeSettings.normal.location; + } else if ('normal' in relaySettings) { + const entryLocation = relaySettings.normal.wireguard.entryLocation; + if (entryLocation !== 'any') { + selectedEntryLocation = entryLocation; + } + + multihopEnabled = relaySettings.normal.wireguard.useMultihop; } - const allowBridgeSelection = state.settings.bridgeState === 'on'; - const locationScope = allowBridgeSelection - ? state.userInterface.locationScope - : LocationScope.relay; + const allowEntrySelection = + (tunnelProtocol === 'openvpn' && state.settings.bridgeState === 'on') || + ((tunnelProtocol === 'any' || tunnelProtocol === 'wireguard') && multihopEnabled); - const relaySettings = state.settings.relaySettings; const providers = 'normal' in relaySettings ? relaySettings.normal.providers : []; return { + locale: state.userInterface.locale, selectedExitLocation, + selectedEntryLocation, selectedBridgeLocation, relayLocations: filterLocationsByProvider(state.settings.relayLocations, providers), bridgeLocations: filterLocationsByProvider(state.settings.bridgeLocations, providers), - locationScope, - allowBridgeSelection, + allowEntrySelection, + tunnelProtocol, providers, + + onSelectEntryLocation: async (entryLocation: RelayLocation) => { + // dismiss the view first + props.history.dismiss(); + + const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings) + .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation)) + .build(); + + try { + await props.app.updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error('Failed to select the entry location', error.message); + } + }, }; }; -const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - const userInterface = bindActionCreators(userInterfaceActions, dispatch); - +const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { return { onClose: () => props.history.dismiss(), onViewFilterByProvider: () => props.history.push(RoutePath.filterByProvider), - onChangeLocationScope: (scope: LocationScope) => { - userInterface.setLocationScope(scope); - }, onSelectExitLocation: async (relayLocation: RelayLocation) => { // dismiss the view first props.history.dismiss(); diff --git a/gui/src/renderer/containers/WireguardSettingsPage.tsx b/gui/src/renderer/containers/WireguardSettingsPage.tsx index 5e6c27105e..4b0af37510 100644 --- a/gui/src/renderer/containers/WireguardSettingsPage.tsx +++ b/gui/src/renderer/containers/WireguardSettingsPage.tsx @@ -1,21 +1,75 @@ import { connect } from 'react-redux'; import { IpVersion } from '../../shared/daemon-rpc-types'; import log from '../../shared/logging'; -import RelaySettingsBuilder from '../../shared/relay-settings-builder'; import WireguardSettings from '../components/WireguardSettings'; import withAppContext, { IAppContext } from '../context'; +import { createWireguardRelayUpdater } from '../lib/constraint-updater'; import { IHistoryProps, withHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { RelaySettingsRedux } from '../redux/settings/reducers'; import { IReduxState, ReduxDispatch } from '../redux/store'; -const mapStateToProps = (state: IReduxState) => { +const mapStateToProps = (state: IReduxState, props: IAppContext) => { const protocolAndPort = mapRelaySettingsToProtocolAndPort(state.settings.relaySettings); + let wireguardMultihop = false; + if ('normal' in state.settings.relaySettings) { + wireguardMultihop = state.settings.relaySettings.normal.wireguard.useMultihop; + } + return { wireguardMtu: state.settings.wireguard.mtu, + wireguardMultihop, ...protocolAndPort, + + setWireguardPort: async (port?: number) => { + const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings) + .tunnel.wireguard((wireguard) => { + if (port) { + wireguard.port.exact(port); + } else { + wireguard.port.any(); + } + }) + .build(); + try { + await props.app.updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error('Failed to update relay settings', error.message); + } + }, + + setWireguardIpVersion: async (ipVersion?: IpVersion) => { + const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings) + .tunnel.wireguard((wireguard) => { + if (ipVersion) { + wireguard.ipVersion.exact(ipVersion); + } else { + wireguard.ipVersion.any(); + } + }) + .build(); + try { + await props.app.updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error('Failed to update relay settings', error.message); + } + }, + + setWireguardMultihop: async (enabled: boolean) => { + const relayUpdate = createWireguardRelayUpdater(state.settings.relaySettings) + .tunnel.wireguard((wireguard) => wireguard.useMultihop(enabled)) + .build(); + try { + await props.app.updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error('Failed to update WireGuard multihop settings', error.message); + } + }, }; }; @@ -46,30 +100,6 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp props.history.pop(); }, - setWireguardRelayPortAndIpVersion: async (port?: number, ipVersion?: IpVersion) => { - const relayUpdate = RelaySettingsBuilder.normal() - .tunnel.wireguard((wireguard) => { - if (port) { - wireguard.port.exact(port); - } else { - wireguard.port.any(); - } - - if (ipVersion) { - wireguard.ipVersion.exact(ipVersion); - } else { - wireguard.ipVersion.any(); - } - }) - .build(); - try { - await props.app.updateRelaySettings(relayUpdate); - } catch (e) { - const error = e as Error; - log.error('Failed to update relay settings', error.message); - } - }, - setWireguardMtu: async (mtu?: number) => { try { await props.app.setWireguardMtu(mtu); diff --git a/gui/src/renderer/lib/constraint-updater.ts b/gui/src/renderer/lib/constraint-updater.ts new file mode 100644 index 0000000000..ce9f49f376 --- /dev/null +++ b/gui/src/renderer/lib/constraint-updater.ts @@ -0,0 +1,36 @@ +import { RelaySettingsRedux } from '../redux/settings/reducers'; +import RelaySettingsBuilder from '../../shared/relay-settings-builder'; + +export function createWireguardRelayUpdater( + relaySettings: RelaySettingsRedux, +): ReturnType<typeof RelaySettingsBuilder['normal']> { + if ('normal' in relaySettings) { + const constraints = relaySettings.normal.wireguard; + + const relayUpdate = RelaySettingsBuilder.normal().tunnel.wireguard((wireguard) => { + if (constraints.port === 'any') { + wireguard.port.any(); + } else { + wireguard.port.exact(constraints.port); + } + + if (constraints.ipVersion === 'any') { + wireguard.ipVersion.any(); + } else { + wireguard.ipVersion.exact(constraints.ipVersion); + } + + wireguard.useMultihop(constraints.useMultihop); + + if (constraints.entryLocation === 'any') { + wireguard.entryLocation.any(); + } else if (constraints.entryLocation !== undefined) { + wireguard.entryLocation.exact(constraints.entryLocation); + } + }); + + return relayUpdate; + } else { + return RelaySettingsBuilder.normal(); + } +} diff --git a/gui/src/renderer/redux/connection/reducers.ts b/gui/src/renderer/redux/connection/reducers.ts index 31551d81ba..ffb0fd1d84 100644 --- a/gui/src/renderer/redux/connection/reducers.ts +++ b/gui/src/renderer/redux/connection/reducers.ts @@ -8,6 +8,7 @@ export interface IConnectionReduxState { ipv6?: Ip; hostname?: string; bridgeHostname?: string; + entryHostname?: string; latitude?: number; longitude?: number; country?: string; @@ -21,6 +22,7 @@ const initialState: IConnectionReduxState = { ipv6: undefined, hostname: undefined, bridgeHostname: undefined, + entryHostname: undefined, latitude: undefined, longitude: undefined, country: undefined, @@ -43,6 +45,7 @@ export default function ( longitude: action.newLocation.longitude, hostname: action.newLocation.hostname, bridgeHostname: action.newLocation.bridgeHostname, + entryHostname: action.newLocation.entryHostname, }; case 'UPDATE_BLOCK_STATE': diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 3e1e5cb19d..5ac19cadf6 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -27,6 +27,8 @@ export type RelaySettingsRedux = wireguard: { port: LiftedConstraint<number>; ipVersion: LiftedConstraint<IpVersion>; + useMultihop: boolean; + entryLocation: LiftedConstraint<RelayLocation>; }; }; } @@ -62,14 +64,12 @@ export interface IRelayLocationCityRedux { code: string; latitude: number; longitude: number; - hasActiveRelays: boolean; relays: IRelayLocationRelayRedux[]; } export interface IRelayLocationRedux { name: string; code: string; - hasActiveRelays: boolean; cities: IRelayLocationCityRedux[]; } @@ -161,7 +161,7 @@ const initialState: ISettingsReduxState = { location: 'any', tunnelProtocol: 'any', providers: [], - wireguard: { port: 'any', ipVersion: 'any' }, + wireguard: { port: 'any', ipVersion: 'any', useMultihop: false, entryLocation: 'any' }, openvpn: { port: 'any', protocol: 'any', diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts index 88ffbf216f..e6010d42f2 100644 --- a/gui/src/renderer/redux/userinterface/actions.ts +++ b/gui/src/renderer/redux/userinterface/actions.ts @@ -1,5 +1,4 @@ import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema'; -import { LocationScope } from './reducers'; export interface IUpdateLocaleAction { type: 'UPDATE_LOCALE'; @@ -15,11 +14,6 @@ export interface IUpdateConnectionInfoOpenAction { type: 'TOGGLE_CONNECTION_PANEL'; } -export interface ISetLocationScopeAction { - type: 'SET_LOCATION_SCOPE'; - scope: LocationScope; -} - export interface ISetWindowFocusedAction { type: 'SET_WINDOW_FOCUSED'; focused: boolean; @@ -50,7 +44,6 @@ export type UserInterfaceAction = | IUpdateLocaleAction | IUpdateWindowArrowPositionAction | IUpdateConnectionInfoOpenAction - | ISetLocationScopeAction | ISetWindowFocusedAction | IAddScrollPosition | IRemoveScrollPosition @@ -77,13 +70,6 @@ function toggleConnectionPanel(): IUpdateConnectionInfoOpenAction { }; } -function setLocationScope(scope: LocationScope): ISetLocationScopeAction { - return { - type: 'SET_LOCATION_SCOPE', - scope, - }; -} - function setWindowFocused(focused: boolean): ISetWindowFocusedAction { return { type: 'SET_WINDOW_FOCUSED', @@ -126,7 +112,6 @@ export default { updateLocale, updateWindowArrowPosition, toggleConnectionPanel, - setLocationScope, setWindowFocused, addScrollPosition, removeScrollPosition, diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts index 5adac07937..9247dc8a5f 100644 --- a/gui/src/renderer/redux/userinterface/reducers.ts +++ b/gui/src/renderer/redux/userinterface/reducers.ts @@ -1,16 +1,10 @@ import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema'; import { ReduxAction } from '../store'; -export enum LocationScope { - bridge = 0, - relay, -} - export interface IUserInterfaceReduxState { locale: string; arrowPosition?: number; connectionPanelVisible: boolean; - locationScope: LocationScope; windowFocused: boolean; scrollPosition: Record<string, [number, number]>; macOsScrollbarVisibility?: MacOsScrollbarVisibility; @@ -20,7 +14,6 @@ export interface IUserInterfaceReduxState { const initialState: IUserInterfaceReduxState = { locale: 'en', connectionPanelVisible: false, - locationScope: LocationScope.relay, windowFocused: false, scrollPosition: {}, macOsScrollbarVisibility: undefined, @@ -41,9 +34,6 @@ export default function ( case 'TOGGLE_CONNECTION_PANEL': return { ...state, connectionPanelVisible: !state.connectionPanelVisible }; - case 'SET_LOCATION_SCOPE': - return { ...state, locationScope: action.scope }; - case 'SET_WINDOW_FOCUSED': return { ...state, windowFocused: action.focused }; diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index 300af97660..3f026b032c 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -14,6 +14,7 @@ export interface ILocation { mullvadExitIp: boolean; hostname?: string; bridgeHostname?: string; + entryHostname?: string; provider?: string; } @@ -88,6 +89,12 @@ export interface ITunnelEndpoint { protocol: RelayProtocol; tunnelType: TunnelType; proxy?: IProxyEndpoint; + entryEndpoint?: IEndpoint; +} + +export interface IEndpoint { + address: string; + transportProtocol: RelayProtocol; } export interface IProxyEndpoint { @@ -133,6 +140,8 @@ export interface IOpenVpnConstraints { export interface IWireguardConstraints { port: Constraint<number>; ipVersion: Constraint<IpVersion>; + useMultihop: boolean; + entryLocation: Constraint<RelayLocation>; } export type TunnelProtocol = 'wireguard' | 'openvpn'; diff --git a/gui/src/shared/relay-settings-builder.ts b/gui/src/shared/relay-settings-builder.ts index 418de10888..e4c11e5132 100644 --- a/gui/src/shared/relay-settings-builder.ts +++ b/gui/src/shared/relay-settings-builder.ts @@ -3,6 +3,7 @@ import { IOpenVpnConstraints, IpVersion, IWireguardConstraints, + RelayLocation, RelayProtocol, RelaySettingsNormalUpdate, RelaySettingsUpdate, @@ -23,6 +24,8 @@ interface IOpenVPNConfigurator { interface IWireguardConfigurator { port: IExactOrAny<number, IWireguardConfigurator>; ipVersion: IExactOrAny<IpVersion, IWireguardConfigurator>; + useMultihop: (value: boolean) => IWireguardConfigurator; + entryLocation: IExactOrAny<RelayLocation, IWireguardConfigurator>; } interface ITunnelProtocolConfigurator { @@ -137,6 +140,22 @@ class NormalRelaySettingsBuilder { any: () => apply('any'), }; }, + get useMultihop() { + return (useMultihop: boolean) => { + updateWireguard({ useMultihop }); + return this; + }; + }, + get entryLocation() { + const apply = (entryLocation: Constraint<RelayLocation> | undefined) => { + updateWireguard({ entryLocation }); + return this; + }; + return { + exact: (entryLocation: RelayLocation) => apply({ only: entryLocation }), + any: () => apply('any'), + }; + }, }; configurator(wireguardBuilder); return this; |
