android/ui: add support for remembering the last used exit node (#305)

Updates ENG-2911

Disabling an exit node is now temporary and you can re-enable it without re-selecting it from the picker.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/341/head
Jonathan Nobels 2 weeks ago committed by GitHub
parent 75e2d8983b
commit f4d2a277a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -46,6 +46,7 @@ private object Endpoint {
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
}
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -85,6 +86,11 @@ class Client(private val scope: CoroutineScope) {
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
return post(path, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}

@ -65,7 +65,22 @@ class Ipn {
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
var InternalExitNodePrior: String? = null,
) {
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
// simplify the downstream logic.
val selectedExitNodeID: String?
get() {
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
}
val activeExitNodeID: String?
get() {
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
}
}
@Serializable
data class MaskedPrefs(
@ -79,6 +94,7 @@ class Ipn {
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) {
var ControlURL: String? = null
@ -105,6 +121,12 @@ class Ipn {
ExitNodeIDSet = true
}
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value

@ -333,6 +333,17 @@ val ColorScheme.onBackgroundLogoDotDisabled: Color
Color(0x66FFFFFF)
}
val ColorScheme.exitNodeToggleButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
return ButtonColors(
containerColor = Color(0xFF4B70CC), // blue-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
val ColorScheme.disabled: Color
get() = Color(0xFFAFACAB) // gray-400

@ -70,6 +70,7 @@ import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
@ -182,13 +183,26 @@ fun MainView(
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState()
val maybePrefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location
val name = peer?.ComputedName
val active = peer != null
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs.value ?: return
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = exitNodePeer?.Hostinfo?.Location
val name = exitNodePeer?.ComputedName
// We're connected to an exit node if we found an active peer for the *active* exit node
val activeAndRunning = (exitNodePeer != null) && !prefs.activeExitNodeID.isNullOrEmpty()
// (jonathan) TODO: We will block the "enable/disable" button for an exit node for which we cannot
// find a peer on purpose and render the "No Exit Node" state, however, that should
// eventually show up in the UI as an error case so the user knows to pick an available node.
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
@ -199,7 +213,7 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
ListItem(
modifier = Modifier.clickable { navAction() },
colors =
if (active) MaterialTheme.colorScheme.primaryListItem
if (activeAndRunning) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
overlineContent = {
Text(
@ -221,17 +235,24 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
if (activeAndRunning)
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
trailingContent = {
if (peer != null) {
if (exitNodePeer != null) {
Button(
colors = MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.disableExitNode() }) {
Text(stringResource(R.string.stop))
colors =
if (prefs.activeExitNodeID.isNullOrEmpty())
MaterialTheme.colorScheme.exitNodeToggleButton
else MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.toggleExitNode() }) {
Text(
if (prefs.activeExitNodeID.isNullOrEmpty())
stringResource(id = R.string.enable)
else stringResource(id = R.string.disable))
}
}
})
@ -470,7 +491,8 @@ fun PeerList(
fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired)
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry) || networkMap.SelfNode.keyDoesNotExpire) {
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry) ||
networkMap.SelfNode.keyDoesNotExpire) {
return
}
@ -518,8 +540,6 @@ fun MainViewPreview() {
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {}),
onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}),
vm)
}

@ -63,7 +63,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
val exitNodeId = prefs?.ExitNodeID
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
netmap?.Peers?.let { peers ->
val allNodes =
peers
@ -137,8 +137,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateBackHome()
nav.onNavigateBackToExitNodes()
LoadingIndicator.stop()
}
}

@ -14,6 +14,8 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.notifier.Notifier.prefs
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -183,4 +185,22 @@ open class IpnViewModel : ViewModel() {
completionHandler(it)
}
}
// Exit Node Manipulation
fun toggleExitNode() {
val prefs = prefs.value ?: return
LoadingIndicator.start()
if (prefs.activeExitNodeID != null) {
// We have an active exit node so we should keep it, but disable it
Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() }
} else if (prefs.selectedExitNodeID != null) {
// We have a prior exit node to enable
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else {
// This should not be possible. In this state the button is hidden
Log.e(TAG, "No exit node to disable and no prior exit node to enable")
}
}
}

@ -3,13 +3,11 @@
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.set
@ -60,18 +58,15 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
}
viewModelScope.launch {
Notifier.prefs.collect { prefs -> Log.d(TAG, "Main VM - prefs = ${prefs}") }
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
}
fun disableExitNode() {
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
}
}
private fun State?.userStringRes(): Int {

@ -64,6 +64,8 @@
<string name="machine_auth_explainer">This device must be approved by an administrator before it can connect to the tailnet.</string>
<string name="open_admin_console">Open admin console</string>
<string name="needs_machine_auth">Authorization required</string>
<string name="disable">Disable</string>
<string name="enable">Enable</string>
<!-- Strings for peer details -->
<string name="os">OS</string>
@ -140,7 +142,6 @@
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="stop">Stop</string>
<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string>

@ -7,7 +7,7 @@ require (
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0
inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.65.0-pre.0.20240417162431-449be38e0381
tailscale.com v1.65.0-pre.0.20240418182715-02c6af2a69a8
)
require (
@ -84,7 +84,7 @@ require (
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/term v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect

@ -201,8 +201,8 @@ golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -258,5 +258,5 @@ nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.65.0-pre.0.20240417162431-449be38e0381 h1:Wiz4SeARcNVLO7rmZy0mPj0RJHcSdHIQ8itrPkgNKyo=
tailscale.com v1.65.0-pre.0.20240417162431-449be38e0381/go.mod h1:PVwaayzADTOctljVKj+M50OtQ0dOGYBLi2fdbOxo6vw=
tailscale.com v1.65.0-pre.0.20240418182715-02c6af2a69a8 h1:8aRe1ZYCD2st8Yu+4l2p6RJsKxVyrh3b+EL1+OmqCHA=
tailscale.com v1.65.0-pre.0.20240418182715-02c6af2a69a8/go.mod h1:Vzqw2fmVnsh7oT1iCqM1ZQlwQru9RjPJmH+srY0Af14=

Loading…
Cancel
Save