Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement USE_EXIT_NODE intent #142

Merged
merged 2 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
</intent-filter>
</receiver>

Expand Down
7 changes: 6 additions & 1 deletion android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ class App : UninitializedApp(), libtailscale.AppContext {
open class UninitializedApp : Application() {
companion object {
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status"

// Key for shared preference that tracks whether or not we're able to start
Expand Down Expand Up @@ -388,6 +389,10 @@ open class UninitializedApp : Application() {
}

fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
}

fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
Expand All @@ -399,7 +404,7 @@ open class UninitializedApp : Application() {
// for ActivityCompat#requestPermissions for more details.
return
}
notificationManager.notify(STATUS_NOTIFICATION_ID, buildStatusNotification(vpnRunning))
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}

fun buildStatusNotification(vpnRunning: Boolean): Notification {
Expand Down
11 changes: 11 additions & 0 deletions android/src/main/java/com/tailscale/ipn/IPNReceiver.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.work.Data;

import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
Expand All @@ -20,6 +21,8 @@ public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";

private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";

@Override
public void onReceive(Context context, Intent intent) {
WorkManager workManager = WorkManager.getInstance(context);
Expand All @@ -30,5 +33,13 @@ public void onReceive(Context context, Intent intent) {
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
}
else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) {
String exitNode = intent.getStringExtra("exitNode");
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
Data.Builder workData = new Data.Builder();
workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode);
workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess);
workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build());
}
}
}
112 changes: 112 additions & 0 deletions android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn

import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

class UseExitNodeWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val app = UninitializedApp.get()
suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME)

val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
null
} else {
if (!app.isAbleToStartVPN()) {
return app.getString(R.string.vpn_is_not_ready_to_start)
}

val peers =
(Notifier.netmap.value
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }

val filteredPeers = peers.filter {
it.displayName == exitNodeName
}.toList()

if (filteredPeers.isEmpty()) {
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
} else if (filteredPeers.size > 1) {
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
} else if (!filteredPeers[0].isExitNode) {
return app.getString(
R.string.peer_with_name_is_not_an_exit_node,
exitNodeName
)
}

filteredPeers[0].StableID
}

val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = exitNodeId
prefsOut.ExitNodeAllowLANAccess = allowLanAccess

val scope = CoroutineScope(Dispatchers.Default + Job())
var result: String? = null
Client(scope).editPrefs(prefsOut) {
result = if (it.isFailure) {
it.exceptionOrNull()?.message
} else {
null
}
}

scope.coroutineContext[Job]?.join()

return result
}

val result = runAndGetResult()

return if (result != null) {
val intent =
Intent(app, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
.setContentText(result)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()

app.notifyStatus(notification)

Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
} else {
Result.success()
}
}

companion object {
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
const val ERROR_KEY = "error"
}
}
9 changes: 9 additions & 0 deletions android/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@
<string name="welcome2">All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>

<!-- Strings for intent handling -->
<string name="vpn_is_not_ready_to_start">VPN is not ready to start</string>
<string name="tailscale_is_not_setup">Tailscale is not setup</string>
<string name="no_peers_found">No peers found</string>
<string name="no_peers_with_name_found">No peers with name %1$s found</string>
<string name="multiple_peers_with_name_found">Multiple peers with name %1$s found</string>
<string name="peer_with_name_is_not_an_exit_node">Peer with name %1$s is not an exit node</string>
<string name="use_exit_node_intent_failed">Use Exit Node Intent Failed</string>

<!-- Strings for the IPNReceiver -->
<string name="title_connection_failed">Tailscale Connection Failed</string>
<string name="body_open_tailscale">Tap here to open Tailscale.</string>
Expand Down