ui: add ability to advertise Android device as subnet router (#595)

pull/598/head
Andrea Gottardo 11 months ago committed by GitHub
parent a2850b1078
commit 9c3378d7eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -69,6 +69,7 @@ import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
@ -185,6 +186,7 @@ class MainActivity : ComponentActivity() {
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToSubnetRouting = { navController.navigate("subnetRouting")},
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
@ -247,6 +249,7 @@ class MainActivity : ComponentActivity() {
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }

@ -24,4 +24,5 @@ object Links {
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val SUBNET_ROUTERS_KB_URL = "https://tailscale.com/kb/1019/subnets"
}

@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a subnet route.
*/
@Composable
fun EditSubnetRouteDialogView(
valueFlow: MutableStateFlow<String>,
isValueValidFlow: StateFlow<Boolean>,
onValueChange: (String) -> Unit,
onCommit: (String) -> Unit,
onCancel: () -> Unit
) {
val value by valueFlow.collectAsState()
val isValueValid by isValueValidFlow.collectAsState()
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(16.dp),
) {
Text(text = stringResource(R.string.enter_valid_route))
Text(
text = stringResource(R.string.route_help_text),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = value,
onValueChange = { onValueChange(it) },
singleLine = true,
isError = !isValueValid,
modifier = Modifier.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.align(Alignment.End)
) {
Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = {
onCancel()
}) {
Text(stringResource(R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onCommit(value)
}, enabled = value.isNotEmpty() && isValueValid) {
Text(stringResource(R.string.ok))
}
}
}
// When the dialog is opened, focus on the text field to present the keyboard auto-magically.
val windowInfo = LocalWindowInfo.current
LaunchedEffect(windowInfo) {
snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused ->
if (isWindowFocused) {
focusRequester.requestFocus()
}
}
}
}

@ -27,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.AlwaysNeverUserDecides
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links
@ -43,7 +44,11 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AppVersion
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
fun SettingsView(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel()
) {
val handler = LocalUriHandler.current
val user by viewModel.loggedInUser.collectAsState()
@ -53,17 +58,22 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()
Scaffold(
topBar = {
Scaffold(topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
Column(
modifier = Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
if (isVPNPrepared) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher)
onClick = settingsNav.onNavigateToUserSwitcher
)
}
if (isAdmin && !isAndroidTV()) {
@ -73,29 +83,34 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Lists.SectionDivider()
Setting.Text(
R.string.dns_settings,
subtitle =
corpDNSEnabled?.let {
R.string.dns_settings, subtitle = corpDNSEnabled?.let {
stringResource(
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
},
onClick = settingsNav.onNavigateToDNSSettings)
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns
)
}, onClick = settingsNav.onNavigateToDNSSettings
)
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
onClick = settingsNav.onNavigateToSplitTunneling
)
if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider()
Setting.Text(
R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
}, onClick = settingsNav.onNavigateToTailnetLock
)
}
if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) {
Lists.ItemDivider()
Setting.Text(
R.string.subnet_routing,
onClick = settingsNav.onNavigateToSubnetRouting
)
}
if (!AndroidTVUtil.isAndroidTV()) {
Lists.ItemDivider()
@ -106,7 +121,8 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Lists.ItemDivider()
Setting.Text(
title = stringResource(R.string.managed_by_orgName, it),
onClick = settingsNav.onNavigateToManagedBy)
onClick = settingsNav.onNavigateToManagedBy
)
}
Lists.SectionDivider()
@ -116,7 +132,8 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Setting.Text(
R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
onClick = settingsNav.onNavigateToAbout)
onClick = settingsNav.onNavigateToAbout
)
// TODO: put a heading for the debug section
if (BuildConfig.DEBUG) {
@ -142,22 +159,22 @@ object Setting {
if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) }
}
ListItem(
modifier = modifier,
ListItem(modifier = modifier,
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified
)
},
supportingContent =
subtitle?.let {
supportingContent = subtitle?.let {
{
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
@ -170,15 +187,12 @@ object Setting {
enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {}
) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
)
},
trailingContent = {
}, trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
})
}
@ -191,10 +205,10 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
style = SpanStyle(
color = MaterialTheme.colorScheme.link, textDecoration = TextDecoration.Underline
)
) {
append(stringResource(id = R.string.settings_admin_link))
}
}
@ -210,5 +224,5 @@ fun SettingsPreview() {
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}

@ -0,0 +1,58 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
/**
* SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route.
* It provides options to edit or delete the route.
*
* @param route The subnet route itself (e.g., "192.168.1.0/24").
* @param onEdit A callback invoked when the edit icon is clicked.
* @param onDelete A callback invoked when the delete icon is clicked.
*/
@Composable
fun SubnetRouteRowView(
route: String, onEdit: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier
) {
ListItem(
headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) },
trailingContent = {
Row {
IconButton(onClick = onEdit) {
Icon(
painterResource(R.drawable.pencil),
contentDescription = stringResource(R.string.edit_route),
modifier = Modifier.size(24.dp)
)
}
IconButton(
onClick = onDelete,
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Icon(
painterResource(R.drawable.xmark),
contentDescription = stringResource(R.string.delete_route),
modifier = Modifier.size(24.dp)
)
}
}
},
modifier = modifier
)
}

@ -0,0 +1,126 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links.SUBNET_ROUTERS_KB_URL
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SubnetRoutingViewModel
@Composable
fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewModel = viewModel()) {
val subnetRoutes by model.advertisedRoutes.collectAsState()
val uriHandler = LocalUriHandler.current
val isPresentingDialog by model.isPresentingDialog.collectAsState()
val useSubnets by model.routeAll.collectAsState()
Scaffold(topBar = {
Header(R.string.subnet_routes, onBack = backToSettings, actions = {
IconButton(onClick = {
uriHandler.openUri(SUBNET_ROUTERS_KB_URL)
}) {
Icon(
painter = painterResource(R.drawable.info), contentDescription = stringResource(
R.string.open_kb_article
)
)
}
})
}) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item("subnetsToggle") {
Setting.Switch(R.string.use_tailscale_subnets, isOn = useSubnets, onToggle = {
LoadingIndicator.start()
model.toggleUseSubnets { LoadingIndicator.stop() }
})
}
item("subtitle") {
ListItem(headlineContent = {
Text(
stringResource(R.string.use_tailscale_subnets_subtitle),
modifier = Modifier.padding(bottom = 8.dp)
)
})
}
item("divider0") {
Lists.SectionDivider()
}
item(key = "header") {
Lists.MutedHeader(stringResource(R.string.advertised_routes))
ListItem(headlineContent = {
Text(
stringResource(R.string.run_as_subnet_router_header),
modifier = Modifier.padding(vertical = 8.dp)
)
})
}
itemsWithDividers(subnetRoutes, key = { it }) {
SubnetRouteRowView(route = it, onEdit = {
model.startEditingRoute(it)
}, onDelete = {
model.deleteRoute(it)
}, modifier = Modifier.animateItem())
}
item("addNewRoute") {
Lists.ItemDivider()
ListItem(headlineContent = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Add, contentDescription = null)
Text(stringResource(R.string.add_new_route))
}
}, modifier = Modifier.clickable { model.startEditingRoute("") })
}
}
}
}
if (isPresentingDialog) {
Dialog(onDismissRequest = {
model.isPresentingDialog.set(false)
}) {
Card {
EditSubnetRouteDialogView(valueFlow = model.dialogTextFieldValue,
isValueValidFlow = model.isTextFieldValueValid,
onValueChange = {
model.dialogTextFieldValue.set(it)
},
onCommit = {
model.doneEditingRoute(newValue = it)
},
onCancel = {
model.stopEditingRoute()
})
}
}
}
}

@ -18,6 +18,7 @@ data class SettingsNav(
val onNavigateToDNSSettings: () -> Unit,
val onNavigateToSplitTunneling: () -> Unit,
val onNavigateToTailnetLock: () -> Unit,
val onNavigateToSubnetRouting: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit,

@ -0,0 +1,247 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
/**
* SubnetRoutingViewModel is responsible for managing the content of the subnet router management view.
* This class watches the backend preferences and updates the UI accordingly whenever the advertised routes
* change. It also handles the state of the editing dialog, and updates the preferences stored in
* the backend when the routes are edited in the UI.
*/
class SubnetRoutingViewModel : ViewModel() {
private val TAG = "SubnetRoutingViewModel"
/**
* Matches the value of the "RouteAll" backend preference.
*/
val routeAll: StateFlow<Boolean> = MutableStateFlow(true)
/**
* The advertised routes displayed at any point in time in the UI. The class observes
* this value for changes, and updates the backend preferences accordingly.
*/
val advertisedRoutes: StateFlow<List<String>> = MutableStateFlow(listOf())
/**
* Whether we are presenting the add/edit dialog to set/change the value of a route.
*/
val isPresentingDialog: StateFlow<Boolean> = MutableStateFlow(false)
/**
* When editing a route, this stores the initial value. It is used to determine which
* of the previously existing routes needs to be updated. This starts as empty, and dismissing
* the edit dialog should reset it to empty as well.
* If the user is adding a new route, this will be empty despite isPresentingDialog being true.
*/
private val editingRoute: StateFlow<String> = MutableStateFlow("")
/**
* The value currently entered in the add/edit dialog text field.
*/
val dialogTextFieldValue: MutableStateFlow<String> = MutableStateFlow("")
/**
* True if the value currently entered in the dialog text field is valid, false otherwise.
* If the text field is empty, this returns true as we don't want to display an error state
* when the user hasn't entered anything.
*/
val isTextFieldValueValid: StateFlow<Boolean> = MutableStateFlow(true)
init {
viewModelScope.launch {
// Any time the value entered by the user in the add/edit dialog changes, we determine
// whether it is valid or invalid, and set isTextFieldValueValid accordingly.
dialogTextFieldValue
.collect { newValue ->
if (newValue.isEmpty()) {
isTextFieldValueValid.set(true)
return@collect
}
val isValid = isValidCIDR(newValue)
Log.v(TAG, "isValidCIDR($newValue): $isValid")
isTextFieldValueValid.set(isValid)
return@collect
}
}
viewModelScope.launch {
// Similarly, if the routes change in the backend at any time, we should also reflect
// that change in the UI.
Notifier.prefs
// Ignore any prefs updates without AdvertiseRoutes
.mapNotNull { it?.AdvertiseRoutes }
// Ignore duplicate values to prevent an unnecessary UI update
.distinctUntilChanged()
// Ignore any value that matches the current value in UI,
// to prevent an unnecessary UI update
.filter { it != advertisedRoutes }.collect { newRoutesFromBackend ->
Log.d(
TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend"
)
advertisedRoutes.set(newRoutesFromBackend)
}
}
viewModelScope.launch {
Notifier.prefs.map { it?.RouteAll }.distinctUntilChanged().collect {
Log.d(TAG, "RouteAll changed in the backend: $it")
routeAll.set(it)
}
}
viewModelScope.launch {
routeAll.collect {
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = it
Log.d(TAG, "Will save RouteAll in the backend: $it")
Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result ->
if (result.isFailure) {
Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}")
return@editPrefs
} else {
Log.d(
TAG,
"RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}"
)
}
})
}
}
}
// Public functions
fun toggleUseSubnets(onDone: () -> Unit) {
routeAll.set(!routeAll.value)
onDone()
}
/**
* Deletes the given subnet route from the list of advertised routes.
* Calling this function will cause the backend preferences to be updated in the background.
*
* @param route The route string to be deleted from the list of advertised routes.
* If the route does not exist in the list, no changes are made.
*/
fun deleteRoute(route: String) {
val currentRoutes = advertisedRoutes.value.toMutableList()
if (!currentRoutes.contains(route)) {
Log.e(TAG, "Attempted to delete route, but it does not exist: $route")
return
}
currentRoutes.remove(route)
advertisedRoutes.set(currentRoutes)
saveRoutesToPrefs()
}
/**
* Starts editing the given subnet route. Called when the user taps the 'pencil' button
* on a route in the list.
*/
fun startEditingRoute(route: String) {
Log.d(TAG, "startEditingRoute: $route")
editingRoute.set(route)
dialogTextFieldValue.set(route)
isPresentingDialog.set(true)
}
/**
* Commits the changes made so far in the editing dialog.
*/
fun doneEditingRoute(newValue: String) {
Log.d(TAG, "doneEditingRoute: $newValue")
editRoute(editingRoute.value, newValue)
stopEditingRoute()
}
/**
* Cancels any current editing session and closes the dialog.
*/
fun stopEditingRoute() {
Log.d(TAG, "stopEditingRoute")
isPresentingDialog.set(false)
dialogTextFieldValue.set("")
editingRoute.set("")
}
/**
* This makes the actual changes whenever adding or editing a route.
* If adding a new route, oldRoute will be empty.
* This function validates the input before making any changes. If newRoute
* is not a valid CIDR IPv4/IPv6 range, this function does nothing.
*/
private fun editRoute(oldRoute: String, newRoute: String) {
val currentRoutes = advertisedRoutes.value.toMutableList()
if (oldRoute == newRoute) {
Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute")
return
}
if (currentRoutes.contains(newRoute)) {
Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute")
return
}
// Verify the newRoute is a valid IPv4 or IPv6 CIDR range.
val isValid = isValidCIDR(newRoute)
if (!isValid) {
Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute")
return
}
val index = currentRoutes.indexOf(oldRoute)
if (index == -1) {
Log.v(TAG, "Adding new route: $newRoute")
currentRoutes.add(newRoute)
} else {
Log.v(TAG, "Updating route at index $index: $newRoute")
currentRoutes[index] = newRoute
}
advertisedRoutes.set(currentRoutes)
saveRoutesToPrefs()
}
private fun saveRoutesToPrefs() {
val prefsOut = Ipn.MaskedPrefs()
prefsOut.AdvertiseRoutes = advertisedRoutes.value
Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)")
Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result ->
if (result.isFailure) {
Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}")
return@editPrefs
} else {
Log.d(
TAG,
"AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}"
)
}
})
}
companion object RouteValidation {
/**
* Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise.
*/
fun isValidCIDR(newRoute: String): Boolean {
val cidrPattern =
Regex("(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR
val ipv6CidrPattern =
Regex("(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR
return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute)
}
}
}

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM160,840Q143,840 131.5,828.5Q120,817 120,800L120,703Q120,687 126,672.5Q132,658 143,647L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L313,817Q302,828 287.5,834Q273,840 257,840L160,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M480,536L284,732Q273,743 256,743Q239,743 228,732Q217,721 217,704Q217,687 228,676L424,480L228,284Q217,273 217,256Q217,239 228,228Q239,217 256,217Q273,217 284,228L480,424L676,228Q687,217 704,217Q721,217 732,228Q743,239 743,256Q743,273 732,284L536,480L732,676Q743,687 743,704Q743,721 732,732Q721,743 704,743Q687,743 676,732L480,536Z"/>
</vector>

@ -304,5 +304,20 @@
<string name="multiple_vpn_explainer">Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN.</string>
<string name="go_to_settings">Go to Settings</string>
<string name="cancel">Cancel</string>
<string name="subnet_routes">Subnet routes</string>
<string name="run_as_subnet_router_header">Advertise routes to machines that are not running Tailscale to make them available in your tailnet. Routes must be approved in the admin console.</string>
<string name="open_kb_article">Open KB Article</string>
<string name="delete_route">Delete route</string>
<string name="edit_route">Edit route</string>
<string name="enter_valid_route">Enter a IPv4 or IPv6 route in CIDR format.</string>
<string name="advertised_routes">Advertised routes</string>
<string name="no_advertised_routes">No advertised routes</string>
<string name="add_new_route">Add route</string>
<string name="invalid_route">Invalid route</string>
<string name="valid_route">Valid route</string>
<string name="route_help_text">e.g. 192.168.1.0/24</string>
<string name="run_as_subnet_router">Run as subnet router</string>
<string name="use_tailscale_subnets_subtitle">Route traffic according to your network\'s rules. Some networks require this to access IP addresses that don\'t start with 100.x.y.z.</string>
<string name="subnet_routing">Subnet routing</string>
</resources>

Loading…
Cancel
Save