diff --git a/.github/licenses.tmpl b/.github/licenses.tmpl index 88f6f52..7cf386a 100644 --- a/.github/licenses.tmpl +++ b/.github/licenses.tmpl @@ -18,3 +18,4 @@ Client][]. See also the dependencies in the [Tailscale CLI][]. - [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}})) {{- end }} - [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE)) + - [Gio UI](https://gioui.org/) ([MIT License](https://git.sr.ht/~eliasnaur/gio/tree/main/item/LICENSE)) diff --git a/.gitignore b/.gitignore index 7f87946..95d8806 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ tailscale.jks #IDE .vscode .idea + +libtailscale.aar +libtailscale-sources.jar diff --git a/Makefile b/Makefile index b33a243..95d7b18 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,6 @@ DEBUG_APK=tailscale-debug.apk RELEASE_AAB=tailscale-release.aab APPID=com.tailscale.ipn AAR=android_legacy/libs/ipn.aar -AAR_NEXTGEN=android/libs/ipn.aar KEYSTORE=tailscale.jks KEYSTORE_ALIAS=tailscale TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) @@ -136,12 +135,6 @@ $(AAR): toolchain checkandroidsdk -ldflags "-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)" \ -buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale -$(AAR_NEXTGEN): $(AAR) - @mkdir -p android/libs && \ - cp $(AAR) $(AAR_NEXTGEN) - -lib: $(AAR_NEXTGEN) - # tailscale-debug.apk builds a debuggable APK with the Google Play SDK. $(DEBUG_APK): $(AAR) (cd android_legacy && ./gradlew test assemblePlayDebug) @@ -152,6 +145,33 @@ apk: $(DEBUG_APK) run: install adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity +GOMOBILE=/tmp/gopath/bin/gomobile +GOBIND=/tmp/gopath/bin/gobind +LIBTAILSCALE=android/libs/libtailscale.aar +LIBTAILSCALE_SOURCES=$(shell find libtailscale -name *.go) go.mod go.sum + +$(GOMOBILE): + @echo "building gomobile" && \ + export GOPATH=/tmp/gopath && \ + mkdir -p $$GOPATH && \ + export PATH=$$PATH:$$GOPATH/bin && \ + go install golang.org/x/mobile/cmd/gomobile@latest + +$(GOBIND): $(GOMOBILE) + @export GOPATH=/tmp/gopath && \ + $(GOMOBILE) init + +gomobile: $(GOBIND) + +$(LIBTAILSCALE): $(LIBTAILSCALE_SOURCES) $(GOBIND) + @export GOPATH=/tmp/gopath && \ + export PATH=$$PATH:$$GOPATH/bin && \ + $(GOMOBILE) bind -target android -androidapi 26 ./libtailscale && \ + mkdir -p android/libs/ && \ + cp libtailscale.aar $(LIBTAILSCALE) + +libtailscale: $(LIBTAILSCALE) + # tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits. # This is effectively what the F-Droid build definition produces. # This is useful for testing on e.g. Amazon Fire Stick devices. @@ -159,12 +179,12 @@ tailscale-fdroid.apk: $(AAR) (cd android_legacy && ./gradlew test assembleFdroidDebug) mv android_legacy/build/outputs/apk/fdroid/debug/android_legacy-fdroid-debug.apk $@ -tailscale-new-fdroid.apk: $(AAR_NEXTGEN) +tailscale-new-fdroid.apk: $(LIBTAILSCALE) (cd android && ./gradlew test assembleFdroidDebug) mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@ -tailscale-new-debug.apk: - (cd android && ./gradlew test buildAllGoLibs assemblePlayDebug) +tailscale-new-debug.apk: $(LIBTAILSCALE) + (cd android && ./gradlew test assemblePlayDebug) mv android/build/outputs/apk/play/debug/android-play-debug.apk $@ tailscale-new-debug: tailscale-new-debug.apk @@ -190,4 +210,4 @@ clean: -rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk -pkill -f gradle -.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib +.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib tailscale-new-debug diff --git a/android/build.gradle b/android/build.gradle index 255d6f1..5743d64 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -96,7 +96,7 @@ dependencies { implementation("io.coil-kt:coil-compose:2.6.0") // Tailscale dependencies. - implementation ':ipn@aar' + implementation ':libtailscale@aar' // Tests testImplementation "junit:junit:4.12" @@ -105,62 +105,3 @@ dependencies { // Non-free dependencies. playImplementation 'com.google.android.gms:play-services-auth:20.7.0' } - -def ndkPath = project.hasProperty('ndkPath') ? project.property('ndkPath') : System.getenv('ANDROID_SDK_ROOT') - -task checkNDK { - doFirst { - if (ndkPath == null) { - throw new GradleException('NDK path not found. Please define ndkPath in local.properties or ANDROID_SDK_HOME environment variable.') - } - } -} - -task buildGoLibArm64(type: Exec) { - inputs.dir '../pkg/tailscale' - outputs.file 'src/main/jniLibs/arm64-v8a/libtailscale.so' - environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android30-clang" - commandLine 'sh', '-c', "GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/arm64-v8a/libtailscale.so ../pkg/tailscale" -} - -task buildGoLibArmeabi(type: Exec) { - inputs.dir '../pkg/tailscale' - outputs.file 'src/main/jniLibs/armeabi-v7a/libtailscale.so' - environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi30-clang" - commandLine 'sh', '-c', "GOOS=android GOARCH=arm CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/armeabi-v7a/libtailscale.so ../pkg/tailscale" -} - -task buildGoLibX86(type: Exec) { - inputs.dir '../pkg/tailscale' - outputs.file 'src/main/jniLibs/x86/libtailscale.so' - environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/i686-linux-android30-clang" - commandLine 'sh', '-c', "GOOS=android GOARCH=386 CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/x86/libtailscale.so ../pkg/tailscale" -} - -task buildGoLibX86_64(type: Exec) { - inputs.dir '../pkg/tailscale' - outputs.file 'src/main/jniLibs/x86_64/libtailscale.so' - environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/x86_64-linux-android30-clang" - commandLine 'sh', '-c', "GOOS=android GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/x86_64/libtailscale.so ../pkg/tailscale" -} - -task buildAllGoLibs { - dependsOn checkNDK, buildGoLibArm64, buildGoLibArmeabi, buildGoLibX86, buildGoLibX86_64 -} - -assemble.dependsOn buildAllGoLibs - -task cleanGoLibs(type: Delete) { - delete 'src/main/jniLibs/arm64-v8a/libtailscale.so', - 'src/main/jniLibs/armeabi-v7a/libtailscale.so', - 'src/main/jniLibs/x86/libtailscale.so', - 'src/main/jniLibs/x86_64/libtailscale.so' -} - -clean.dependsOn cleanGoLibs - -tasks.whenTaskAdded { task -> - if (task.name.startsWith('merge') && task.name.endsWith('JniLibFolders')) { - task.mustRunAfter buildAllGoLibs - } -} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 897c5e0..e87b8bb 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -37,7 +37,6 @@ android:exported="true" android:label="@string/app_name" android:launchMode="singleTask" - android:theme="@style/Theme.GioApp" android:windowSoftInputMode="adjustResize"> diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index ed6f4b9..81021d9 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -33,6 +33,7 @@ import android.os.Handler; import android.os.Looper; import android.provider.MediaStore; import android.provider.Settings; +import android.util.Log; import androidx.browser.customtabs.CustomTabsIntent; import androidx.core.app.NotificationCompat; @@ -46,6 +47,8 @@ import com.tailscale.ipn.mdm.BooleanSetting; import com.tailscale.ipn.mdm.MDMSettings; import com.tailscale.ipn.mdm.ShowHideSetting; import com.tailscale.ipn.mdm.StringSetting; +import com.tailscale.ipn.ui.localapi.Request; +import com.tailscale.ipn.ui.notifier.Notifier; import java.io.File; import java.io.IOException; @@ -57,7 +60,9 @@ import java.util.Collections; import java.util.List; import java.util.Objects; -public class App extends Application { +import libtailscale.Libtailscale; + +public class App extends Application implements libtailscale.AppContext { static final String STATUS_CHANNEL_ID = "tailscale-status"; static final int STATUS_NOTIFICATION_ID = 1; static final String NOTIFY_CHANNEL_ID = "tailscale-notify"; @@ -74,9 +79,17 @@ public class App extends Application { private ConnectivityManager connectivityManager; public static App getApplication() { + // TODO: this should be injected to MDMSettings by grabbing it from the activity's context + // rather than being a static singleton. return _application; } + private libtailscale.Application app; + + public libtailscale.Application getTailscaleApp() { + return app; + } + private static boolean isEmpty(String str) { return str == null || str.length() == 0; } @@ -86,34 +99,33 @@ public class App extends Application { f.startActivityForResult(intent, request); } - static native void initBackend(byte[] dataDir, Context context); - - static native void onVPNPrepared(); - - private static native void onDnsConfigChanged(); + public DnsConfig getDnsConfigObj() { + return this.dns; + } - static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes); + @Override + public String getPlatformDNSConfig() { + return dns.getDnsConfigAsString(); + } - static native void onWriteStorageGranted(); + @Override + public boolean isPlayVersion() { + return MaybeGoogle.isGoogle(); + } - public DnsConfig getDnsConfigObj() { - return this.dns; + @Override + public void log(String s, String s1) { + Log.d(s, s1); } @Override public void onCreate() { super.onCreate(); - System.loadLibrary("tailscale"); - String dataDir = this.getFilesDir().getAbsolutePath(); - byte[] dataDirUTF8; - try { - dataDirUTF8 = dataDir.getBytes("UTF-8"); - initBackend(dataDirUTF8, this); - } catch (Exception e) { - android.util.Log.d("tailscale", "Error getting directory"); - } + app = Libtailscale.start(dataDir, this); + Request.setApp(app); + Notifier.setApp(app); this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE); setAndRegisterNetworkCallbacks(); @@ -145,13 +157,13 @@ public class App extends Application { } dns.updateDNSFromNetwork(sb.toString()); - onDnsConfigChanged(); + Libtailscale.onDnsConfigChanged(); } @Override public void onLost(Network network) { super.onLost(network); - onDnsConfigChanged(); + Libtailscale.onDnsConfigChanged(); } }); } @@ -221,7 +233,7 @@ public class App extends Application { return getModelName(); } - String getModelName() { + public String getModelName() { String manu = Build.MANUFACTURER; String model = Build.MODEL; // Strip manufacturer from model. @@ -233,7 +245,7 @@ public class App extends Application { return manu + " " + model; } - String getOSVersion() { + public String getOSVersion() { return Build.VERSION.RELEASE; } @@ -259,7 +271,7 @@ public class App extends Application { }); } - boolean isChromeOS() { + public boolean isChromeOS() { return getPackageManager().hasSystemFeature("android.hardware.type.pc"); } @@ -269,7 +281,7 @@ public class App extends Application { public void run() { Intent intent = VpnService.prepare(act); if (intent == null) { - onVPNPrepared(); + Libtailscale.onVPNPrepared(); } else { startActivityForResult(act, intent, reqCode); } @@ -386,7 +398,7 @@ public class App extends Application { // // Where the fields are: // name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N; - String getInterfacesAsString() { + public String getInterfacesAsString() { List interfaces; try { interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); diff --git a/android/src/main/java/com/tailscale/ipn/IPNActivity.java b/android/src/main/java/com/tailscale/ipn/IPNActivity.java index 928abe6..c5eb741 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNActivity.java +++ b/android/src/main/java/com/tailscale/ipn/IPNActivity.java @@ -14,6 +14,8 @@ import android.provider.OpenableColumns; import java.util.List; +import libtailscale.Libtailscale; + public final class IPNActivity extends Activity { final static int WRITE_STORAGE_RESULT = 1000; @@ -82,14 +84,15 @@ public final class IPNActivity extends Activity { nfiles++; } } - App.onShareIntent(nfiles, types, mimes, items, names, sizes); + // TODO(oxtoacart): actually implement this +// App.onShareIntent(nfiles, types, mimes, items, names, sizes); } @Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) { if (reqCode == WRITE_STORAGE_RESULT) { if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) { - App.onWriteStorageGranted(); + Libtailscale.onWriteStorageGranted(); } } } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.java b/android/src/main/java/com/tailscale/ipn/IPNService.java index f72f6cc..2678a77 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.java +++ b/android/src/main/java/com/tailscale/ipn/IPNService.java @@ -13,10 +13,21 @@ import android.system.OsConstants; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; -public class IPNService extends VpnService { +import java.util.UUID; + +import libtailscale.Libtailscale; + +public class IPNService extends VpnService implements libtailscale.IPNService { public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN"; public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"; + private final String randomID = UUID.randomUUID().toString(); + + @Override + public String id() { + return randomID; + } + @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) { @@ -30,21 +41,17 @@ public class IPNService extends VpnService { i.setPackage(getPackageName()); i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class); sendBroadcast(i); - requestVPN(); - connect(); + Libtailscale.requestVPN(this); return START_STICKY; } - requestVPN(); + Libtailscale.requestVPN(this); App app = ((App) getApplicationContext()); - if (app.vpnReady && app.autoConnect) { - connect(); - } return START_STICKY; } private void close() { stopForeground(true); - disconnect(); + Libtailscale.serviceDisconnect(this); } @Override @@ -71,7 +78,7 @@ public class IPNService extends VpnService { } } - protected VpnService.Builder newBuilder() { + public libtailscale.VPNServiceBuilder newBuilder() { VpnService.Builder b = new VpnService.Builder() .setConfigureIntent(configIntent()) .allowFamily(OsConstants.AF_INET) @@ -100,7 +107,7 @@ public class IPNService extends VpnService { // Google Chromecast https://github.com/tailscale/tailscale/issues/3636 this.disallowApp(b, "com.google.android.apps.chromecast.app"); - return b; + return new VPNServiceBuilder(b); } public void notify(String title, String message) { @@ -127,10 +134,4 @@ public class IPNService extends VpnService { startForeground(App.STATUS_NOTIFICATION_ID, builder.build()); } - - private native void requestVPN(); - - private native void disconnect(); - - private native void connect(); } diff --git a/android/src/main/java/com/tailscale/ipn/MaybeGoogle.java b/android/src/main/java/com/tailscale/ipn/MaybeGoogle.java new file mode 100644 index 0000000..2eb3bac --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/MaybeGoogle.java @@ -0,0 +1,35 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn; + +import android.app.Activity; + +import java.lang.reflect.Method; + +public class MaybeGoogle { + static boolean isGoogle() { + return getGoogle() != null; + } + + static String getIdTokenForActivity(Activity act) { + Class google = getGoogle(); + if (google == null) { + return ""; + } + try { + Method method = google.getMethod("getIdTokenForActivity", Activity.class); + return (String) method.invoke(null, act); + } catch (Exception e) { + return ""; + } + } + + private static Class getGoogle() { + try { + return Class.forName("com.tailscale.ipn.Google"); + } catch (ClassNotFoundException e) { + return null; + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/Peer.java b/android/src/main/java/com/tailscale/ipn/Peer.java index 9bbb025..bf4a647 100644 --- a/android/src/main/java/com/tailscale/ipn/Peer.java +++ b/android/src/main/java/com/tailscale/ipn/Peer.java @@ -3,15 +3,14 @@ package com.tailscale.ipn; -import android.app.Activity; import android.app.Fragment; import android.content.Intent; -public class Peer extends Fragment { - private static native void onActivityResult0(Activity act, int reqCode, int resCode); +import libtailscale.Libtailscale; +public class Peer extends Fragment { @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - onActivityResult0(getActivity(), requestCode, resultCode); + Libtailscale.onActivityResult(requestCode, resultCode, MaybeGoogle.getIdTokenForActivity(getActivity())); } } diff --git a/android/src/main/java/com/tailscale/ipn/VPNServiceBuilder.kt b/android/src/main/java/com/tailscale/ipn/VPNServiceBuilder.kt new file mode 100644 index 0000000..a2c24be --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/VPNServiceBuilder.kt @@ -0,0 +1,40 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.net.VpnService +import libtailscale.ParcelFileDescriptor + +class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder { + override fun addAddress(p0: String, p1: Int) { + builder.addAddress(p0, p1) + } + + override fun addDNSServer(p0: String) { + builder.addDnsServer(p0) + } + + override fun addRoute(p0: String, p1: Int) { + builder.addRoute(p0, p1) + } + + override fun addSearchDomain(p0: String) { + builder.addSearchDomain(p0) + } + + override fun establish(): ParcelFileDescriptor? { + return builder.establish()?.let { ParcelFileDescriptor(it) } + } + + override fun setMTU(p0: Long) { + TODO("Not yet implemented") + } +} + +class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) : + libtailscale.ParcelFileDescriptor { + override fun detach(): Int { + return fd.detachFd() + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 09ef706..580ad36 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -9,7 +9,7 @@ import com.tailscale.ipn.ui.model.Errors import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnState -import kotlinx.coroutines.CompletableDeferred +import com.tailscale.ipn.ui.util.InputStreamAdapter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -18,6 +18,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer +import java.nio.charset.Charset import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -98,7 +99,7 @@ class Client(private val scope: CoroutineScope) { return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler) } - fun startLoginInteractive(responseHandler: (Result) -> Unit) { + fun startLoginInteractive(responseHandler: (Result) -> Unit) { return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler) } @@ -185,6 +186,7 @@ class Request( private val method: String, path: String, private val body: ByteArray? = null, + private val timeoutMillis: Long = 30000, private val responseType: KType, private val responseHandler: (Result) -> Unit ) { @@ -194,63 +196,58 @@ class Request( private const val TAG = "LocalAPIRequest" private val jsonDecoder = Json { ignoreUnknownKeys = true } - private val isReady = CompletableDeferred() - // Called by the backend when the localAPI is ready to accept requests. + private lateinit var app: libtailscale.Application + @JvmStatic - @Suppress("unused") - fun onReady() { - isReady.complete(true) - Log.d(TAG, "Ready") + fun setApp(newApp: libtailscale.Application) { + app = newApp } } - // Perform a request to the local API in the go backend. This is - // the primary JNI method for servicing a localAPI call. This - // is GUARANTEED to call back into onResponse. - // @see cmd/localapiclient/localapishim.go - // - // method: The HTTP method to use. - // request: The path to the localAPI endpoint. - // body: The body of the request. - private external fun doRequest(method: String, request: String, body: ByteArray?) - + @OptIn(ExperimentalSerializationApi::class) fun execute() { scope.launch(Dispatchers.IO) { - isReady.await() - Log.d(TAG, "Executing request:${method}:${fullPath}") - doRequest(method, fullPath, body) - } - } - - // This is called from the JNI layer to publish responses. - @OptIn(ExperimentalSerializationApi::class) - @Suppress("unused", "UNCHECKED_CAST") - fun onResponse(respData: ByteArray) { - Log.d(TAG, "Response for request: $fullPath") - - val response: Result = - when (responseType) { - typeOf() -> Result.success(respData.decodeToString() as T) - else -> - try { - Result.success( - jsonDecoder.decodeFromStream( - Json.serializersModule.serializer(responseType), respData.inputStream()) - as T) - } catch (t: Throwable) { - // If we couldn't parse the response body, assume it's an error response - try { - val error = - jsonDecoder.decodeFromStream(respData.inputStream()) - throw Exception(error.error) - } catch (t: Throwable) { - Result.failure(t) - } - } + Log.d(TAG, "Executing request:${method}:${fullPath} on app $app") + try { + val resp = + app.callLocalAPI( + timeoutMillis, method, fullPath, body?.let { InputStreamAdapter(it.inputStream()) }) + // TODO: use the streaming body for performance + Log.d(TAG, "Got Response") + val respData = resp.bodyBytes() + Log.d(TAG, "Got response body") + val response: Result = + when (responseType) { + typeOf() -> Result.success(respData.decodeToString() as T) + typeOf() -> Result.success(Unit as T) + else -> + try { + Result.success( + jsonDecoder.decodeFromStream( + Json.serializersModule.serializer(responseType), respData.inputStream()) + as T) + } catch (t: Throwable) { + // If we couldn't parse the response body, assume it's an error response + try { + val error = + jsonDecoder.decodeFromStream(respData.inputStream()) + throw Exception(error.error) + } catch (t: Throwable) { + Result.failure(t) + } + } + } + if (resp.statusCode() >= 400) { + throw Exception( + "Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}") } - - // The response handler will invoked internally by the request parser - scope.launch { responseHandler(response) } + // The response handler will invoked internally by the request parser + scope.launch { responseHandler(response) } + } catch (e: Exception) { + Log.e(TAG, "Error executing request:${method}:${fullPath}: $e") + scope.launch { responseHandler(Result.failure(e)) } + } + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 7eceb32..d8021fd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -8,7 +8,6 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.util.set -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -29,7 +28,6 @@ import kotlinx.serialization.json.decodeFromStream object Notifier { private val TAG = Notifier::class.simpleName private val decoder = Json { ignoreUnknownKeys = true } - private val isReady = CompletableDeferred() val state: StateFlow = MutableStateFlow(Ipn.State.NoState) val netmap: StateFlow = MutableStateFlow(null) @@ -39,61 +37,48 @@ object Notifier { val browseToURL: StateFlow = MutableStateFlow(null) val loginFinished: StateFlow = MutableStateFlow(null) val version: StateFlow = MutableStateFlow(null) - - // Indicates whether or not we have granted permission to use the VPN. This must be - // explicitly set by the main activity. null indicates that we have not yet - // checked. val vpnPermissionGranted: StateFlow = MutableStateFlow(null) - // Called by the backend when the localAPI is ready to accept requests. + private lateinit var app: libtailscale.Application + private var manager: libtailscale.NotificationManager? = null + @JvmStatic - @Suppress("unused") - fun onReady() { - isReady.complete(true) - Log.d(TAG, "Ready") + fun setApp(newApp: libtailscale.Application) { + app = newApp } + @OptIn(ExperimentalSerializationApi::class) fun start(scope: CoroutineScope) { Log.d(TAG, "Starting") scope.launch(Dispatchers.IO) { - // Wait for the notifier to be ready - isReady.await() val mask = NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value - startIPNBusWatcher(mask) + manager = + app.watchNotifications(mask.toLong()) { notification -> + val notify = decoder.decodeFromStream(notification.inputStream()) + notify.State?.let { state.set(Ipn.State.fromInt(it)) } + notify.NetMap?.let(netmap::set) + notify.Prefs?.let(prefs::set) + notify.Engine?.let(engineStatus::set) + notify.TailFSShares?.let(tailFSShares::set) + notify.BrowseToURL?.let(browseToURL::set) + notify.LoginFinished?.let { loginFinished.set(it.property) } + notify.Version?.let(version::set) + } Log.d(TAG, "Stopped") } } fun stop() { Log.d(TAG, "Stopping") - stopIPNBusWatcher() - } - - // Callback from jni when a new notification is received - @OptIn(ExperimentalSerializationApi::class) - @JvmStatic - @Suppress("unused") - fun onNotify(notification: ByteArray) { - val notify = decoder.decodeFromStream(notification.inputStream()) - notify.State?.let { state.set(Ipn.State.fromInt(it)) } - notify.NetMap?.let(netmap::set) - notify.Prefs?.let(prefs::set) - notify.Engine?.let(engineStatus::set) - notify.TailFSShares?.let(tailFSShares::set) - notify.BrowseToURL?.let(browseToURL::set) - notify.LoginFinished?.let { loginFinished.set(it.property) } - notify.Version?.let(version::set) + manager?.let { + it.stop() + manager = null + } } - // Starts watching the IPN Bus. This is blocking. - private external fun startIPNBusWatcher(mask: Int) - - // Stop watching the IPN Bus. This is non-blocking. - private external fun stopIPNBusWatcher() - // NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which // what we want to see on the Notify bus private enum class NotifyWatchOpt(val value: Int) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt new file mode 100644 index 0000000..798f4b0 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/InputStreamAdapter.kt @@ -0,0 +1,21 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import java.io.InputStream + +class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream { + override fun read(): ByteArray? { + val b = ByteArray(4096) + val i = inputStream.read(b) + if (i == -1) { + return null + } + return b.sliceArray(0..i) + } + + override fun close() { + inputStream.close() + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 633e6aa..1933856 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -100,7 +100,7 @@ open class IpnViewModel : ViewModel() { context.sendBroadcast(intent) } - fun login(completionHandler: (Result) -> Unit = {}) { + fun login(completionHandler: (Result) -> Unit = {}) { Client(viewModelScope).startLoginInteractive { result -> result .onSuccess { Log.d(TAG, "Login started: $it") } diff --git a/go.mod b/go.mod index f274c46..d99da41 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,9 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 golang.org/x/exp v0.0.0-20240119083558-1b970713d09a - golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 - golang.org/x/sys v0.17.0 + golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63 + 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.61.0-pre.0.20240311120500-7429e8912acb ) @@ -85,15 +86,15 @@ require ( go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect - golang.org/x/crypto v0.18.0 // indirect + golang.org/x/crypto v0.21.0 // indirect golang.org/x/image v0.15.0 // indirect - golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.20.0 // indirect + golang.org/x/mod v0.16.0 // indirect + golang.org/x/net v0.22.0 // indirect golang.org/x/sync v0.6.0 // indirect - golang.org/x/term v0.16.0 // indirect + golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.17.0 // indirect + golang.org/x/tools v0.19.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect diff --git a/go.sum b/go.sum index e7dc126..dace082 100644 --- a/go.sum +++ b/go.sum @@ -477,15 +477,15 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20210722180016-6781d3edade3/go.mod h1:DVyR6MI7P4kEQgvZJSj1fQGrWIi2RzIrfYWycwheUAc= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= -golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0= -golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8= +golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63 h1:3AGKexOYqL+ztdWdkB1bDwXgPBuTS/S8A4WzuTvJ8Cg= +golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -499,14 +499,16 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 h1:dm00oNtDy265HReLTARPfIDXTRb2IG0jqQVpn7p5MKE= +golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87/go.mod h1:DN+F2TpepQEh5goqWnM3gopfFakSWM8OmHiz0rPRjT4= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -525,8 +527,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/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.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -568,11 +570,11 @@ golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -600,8 +602,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/tailscale/backend.go b/libtailscale/backend.go similarity index 55% rename from pkg/tailscale/backend.go rename to libtailscale/backend.go index 51b5e20..58053af 100644 --- a/pkg/tailscale/backend.go +++ b/libtailscale/backend.go @@ -1,15 +1,19 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -package main +package libtailscale import ( "context" "fmt" "log" + "net/http" + "os" + "path/filepath" + "runtime/debug" + "sync" + "sync/atomic" - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" - "github.com/tailscale/tailscale-android/pkg/localapiservice" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/ipn/ipnlocal" @@ -29,12 +33,44 @@ import ( "tailscale.com/wgengine/router" ) -import "C" +type App struct { + dataDir string + // appCtx is a global reference to the com.tailscale.ipn.App instance. + appCtx AppContext -type BackendState struct { - State ipn.State - NetworkMap *netmap.NetworkMap - LostInternet bool + store *stateStore + logIDPublicAtomic atomic.Pointer[logid.PublicID] + + localAPIHandler http.Handler + backend *ipnlocal.LocalBackend + ready sync.WaitGroup +} + +func start(dataDir string, appCtx AppContext) Application { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in Start %s: %s", p, debug.Stack()) + panic(p) + } + }() + + initLogging(appCtx) + // Set XDG_CACHE_HOME to make os.UserCacheDir work. + if _, exists := os.LookupEnv("XDG_CACHE_HOME"); !exists { + cachePath := filepath.Join(dataDir, "cache") + os.Setenv("XDG_CACHE_HOME", cachePath) + } + // Set XDG_CONFIG_HOME to make os.UserConfigDir work. + if _, exists := os.LookupEnv("XDG_CONFIG_HOME"); !exists { + cfgPath := filepath.Join(dataDir, "config") + os.Setenv("XDG_CONFIG_HOME", cfgPath) + } + // Set HOME to make os.UserHomeDir work. + if _, exists := os.LookupEnv("HOME"); !exists { + os.Setenv("HOME", dataDir) + } + + return newApp(dataDir, appCtx) } type backend struct { @@ -54,20 +90,15 @@ type backend struct { // when no nameservers are provided by Tailscale. avoidEmptyDNS bool - jvm *jnipkg.JVM - appCtx jnipkg.Object + appCtx AppContext } type settingsFunc func(*router.Config, *dns.OSConfig) error func (a *App) runBackend(ctx context.Context) error { - appDir, err := dataDir() - if err != nil { - fatalErr(err) - } - paths.AppSharedDir.Store(appDir) + paths.AppSharedDir.Store(a.dataDir) hostinfo.SetOSVersion(a.osVersion()) - if !googleSignInEnabled() { + if !a.appCtx.IsPlayVersion() { hostinfo.SetPackage("nogoogle") } deviceModel := a.modelName() @@ -82,7 +113,7 @@ func (a *App) runBackend(ctx context.Context) error { } configs := make(chan configPair) configErrs := make(chan error) - b, err := newBackend(appDir, a.jvm, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { + b, err := newBackend(a.dataDir, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { if rcfg == nil { return nil } @@ -99,11 +130,9 @@ func (a *App) runBackend(ctx context.Context) error { h := localapi.NewHandler(b.backend, log.Printf, b.sys.NetMon.Get(), *a.logIDPublicAtomic.Load()) h.PermitRead = true h.PermitWrite = true - a.localAPI = localapiservice.New(h) + a.localAPIHandler = h - // Share the localAPI with the JNI shim - //localapiservice.SetLocalAPIService(a.localAPI) - localapiservice.ConfigureShim(a.jvm, a.appCtx, a.localAPI, b.backend) + a.ready.Done() // Contrary to the documentation for VpnService.Builder.addDnsServer, // ChromeOS doesn't fall back to the underlying network nameservers if @@ -111,68 +140,72 @@ func (a *App) runBackend(ctx context.Context) error { b.avoidEmptyDNS = a.isChromeOS() var ( - cfg configPair - state BackendState - service jnipkg.Object // of IPNService + cfg configPair + state ipn.State + networkMap *netmap.NetworkMap + service IPNService ) + + stateCh := make(chan ipn.State) + netmapCh := make(chan *netmap.NetworkMap) + go b.backend.WatchNotifications(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState, func() {}, func(notify *ipn.Notify) bool { + if notify.State != nil { + stateCh <- *notify.State + } + if notify.NetMap != nil { + netmapCh <- notify.NetMap + } + return true + }) for { select { + case s := <-stateCh: + state = s + case n := <-netmapCh: + networkMap = n case c := <-configs: cfg = c - if b == nil || service == 0 || cfg.rcfg == nil { + if b == nil || service == nil || cfg.rcfg == nil { configErrs <- nil break } configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg) case s := <-onVPNRequested: - jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - if jnipkg.IsSameObject(env, s, service) { - // We already have a reference. - jnipkg.DeleteGlobalRef(env, s) - return nil - } - if service != 0 { - jnipkg.DeleteGlobalRef(env, service) + if service != nil && service.ID() == s.ID() { + // Still the same VPN instance, do nothing + break + } + netns.SetAndroidProtectFunc(func(fd int) error { + if !s.Protect(int32(fd)) { + // TODO(bradfitz): return an error back up to netns if this fails, once + // we've had some experience with this and analyzed the logs over a wide + // range of Android phones. For now we're being paranoid and conservative + // and do the JNI call to protect best effort, only logging if it fails. + // The risk of returning an error is that it breaks users on some Android + // versions even when they're not using exit nodes. I'd rather the + // relatively few number of exit node users file bug reports if Tailscale + // doesn't work and then we can look for this log print. + log.Printf("[unexpected] VpnService.protect(%d) returned false", fd) } - netns.SetAndroidProtectFunc(func(fd int) error { - return jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - // Call https://developer.android.com/reference/android/net/VpnService#protect(int) - // to mark fd as a socket that should bypass the VPN and use the underlying network. - cls := jnipkg.GetObjectClass(env, s) - m := jnipkg.GetMethodID(env, cls, "protect", "(I)Z") - ok, err := jnipkg.CallBooleanMethod(env, s, m, jnipkg.Value(fd)) - // TODO(bradfitz): return an error back up to netns if this fails, once - // we've had some experience with this and analyzed the logs over a wide - // range of Android phones. For now we're being paranoid and conservative - // and do the JNI call to protect best effort, only logging if it fails. - // The risk of returning an error is that it breaks users on some Android - // versions even when they're not using exit nodes. I'd rather the - // relatively few number of exit node users file bug reports if Tailscale - // doesn't work and then we can look for this log print. - if err != nil || !ok { - log.Printf("[unexpected] VpnService.protect(%d) = %v, %v", fd, ok, err) - } - return nil // even on error. see big TODO above. - }) - }) - log.Printf("onVPNRequested: rebind required") - // TODO(catzkorn): When we start the android application - // we bind sockets before we have access to the VpnService.protect() - // function which is needed to avoid routing loops. When we activate - // the service we get access to the protect, but do not retrospectively - // protect the sockets already opened, which breaks connectivity. - // As a temporary fix, we rebind and protect the magicsock.Conn on connect - // which restores connectivity. - // See https://github.com/tailscale/corp/issues/13814 - b.backend.DebugRebind() - - service = s - return nil + return nil // even on error. see big TODO above. }) - if m := state.NetworkMap; m != nil { + log.Printf("onVPNRequested: rebind required") + // TODO(catzkorn): When we start the android application + // we bind sockets before we have access to the VpnService.protect() + // function which is needed to avoid routing loops. When we activate + // the service we get access to the protect, but do not retrospectively + // protect the sockets already opened, which breaks connectivity. + // As a temporary fix, we rebind and protect the magicsock.Conn on connect + // which restores connectivity. + // See https://github.com/tailscale/corp/issues/13814 + b.backend.DebugRebind() + + service = s + + if networkMap != nil { // TODO } - if cfg.rcfg != nil && state.State >= ipn.Starting { + if cfg.rcfg != nil && state >= ipn.Starting { if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { log.Printf("VPN update failed: %v", err) notifyVPNClosed() @@ -180,16 +213,11 @@ func (a *App) runBackend(ctx context.Context) error { } case s := <-onDisconnect: b.CloseTUNs() - jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - defer jnipkg.DeleteGlobalRef(env, s) - if jnipkg.IsSameObject(env, service, s) { - netns.SetAndroidProtectFunc(nil) - jnipkg.DeleteGlobalRef(env, service) - service = 0 - } - return nil - }) - if state.State >= ipn.Starting { + if service != nil && service.ID() == s.ID() { + netns.SetAndroidProtectFunc(nil) + service = nil + } + if state >= ipn.Starting { notifyVPNClosed() } case <-onDNSConfigChanged: @@ -200,7 +228,7 @@ func (a *App) runBackend(ctx context.Context) error { } } -func newBackend(dataDir string, jvm *jnipkg.JVM, appCtx jnipkg.Object, store *stateStore, +func newBackend(dataDir string, appCtx AppContext, store *stateStore, settings settingsFunc) (*backend, error) { sys := new(tsd.System) @@ -208,7 +236,6 @@ func newBackend(dataDir string, jvm *jnipkg.JVM, appCtx jnipkg.Object, store *st logf := logger.RusagePrefixLog(log.Printf) b := &backend{ - jvm: jvm, devices: newTUNDevices(), settings: settings, appCtx: appCtx, @@ -281,5 +308,12 @@ func newBackend(dataDir string, jvm *jnipkg.JVM, appCtx jnipkg.Object, store *st b.engine = engine b.backend = lb b.sys = sys + go func() { + err := lb.Start(ipn.Options{}) + if err != nil { + log.Printf("Failed to start LocalBackend, panicking: %s", err) + panic(err) + } + }() return b, nil } diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go new file mode 100644 index 0000000..89873a7 --- /dev/null +++ b/libtailscale/callbacks.go @@ -0,0 +1,129 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "sync" +) + +var ( + // onVPNPrepared is notified when VpnService.prepare succeeds. + onVPNPrepared = make(chan struct{}, 1) + // onVPNClosed is notified when VpnService.prepare fails, or when + // the a running VPN connection is closed. + onVPNClosed = make(chan struct{}, 1) + // onVPNRevoked is notified whenever the VPN service is revoked. + onVPNRevoked = make(chan struct{}, 1) + + // onVPNRequested receives global IPNService references when + // a VPN connection is requested. + onVPNRequested = make(chan IPNService) + // onDisconnect receives global IPNService references when + // disconnecting. + onDisconnect = make(chan IPNService) + + // onGoogleToken receives google ID tokens. + onGoogleToken = make(chan string) + + // onWriteStorageGranted is notified when we are granted WRITE_STORAGE_PERMISSION. + onWriteStorageGranted = make(chan struct{}, 1) + + // onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. + onDNSConfigChanged = make(chan struct{}, 1) +) + +const ( + // Request codes for Android callbacks. + // requestSignin is for Google Sign-In. + requestSignin = 1000 + iota + // requestPrepareVPN is for when Android's VpnService.prepare + // completes. + requestPrepareVPN +) + +// resultOK is Android's Activity.RESULT_OK. +const resultOK = -1 + +func OnShareIntent(nfiles int32, types []int32, mimes []string, items []string, names []string, sizes []int) { + // TODO(oxtoacart): actually implement this + // const ( + // typeNone = 0 + // typeInline = 1 + // typeURI = 2 + // ) + // jenv := (*jni.Env)(unsafe.Pointer(env)) + // var files []File + // for i := 0; i < int(nfiles); i++ { + // f := File{ + // Type: FileType(types[i]), + // MIMEType: mimes[i], + // Name: names[i], + // } + // if f.Name == "" { + // f.Name = "file.bin" + // } + // switch f.Type { + // case FileTypeText: + // f.Text = items[i] + // f.Size = int64(len(f.Text)) + // case FileTypeURI: + // f.URI = items[i] + // f.Size = sizes[i] + // default: + // panic("unknown file type") + // } + // files = append(files, f) + // } + // select { + // case <-onFileShare: + // default: + // } + // onFileShare <- files +} + +func OnDnsConfigChanged() { + select { + case onDNSConfigChanged <- struct{}{}: + default: + } +} + +//export Java_com_tailscale_ipn_App_onWriteStorageGranted +func OnWriteStorageGranted() { + select { + case onWriteStorageGranted <- struct{}{}: + default: + } +} + +func notifyVPNPrepared() { + select { + case onVPNPrepared <- struct{}{}: + default: + } +} + +func notifyVPNRevoked() { + select { + case onVPNRevoked <- struct{}{}: + default: + } +} + +func notifyVPNClosed() { + select { + case onVPNClosed <- struct{}{}: + default: + } +} + +var android struct { + // mu protects all fields of this structure. However, once a + // non-nil jvm is returned from javaVM, all the other fields may + // be accessed unlocked. + mu sync.Mutex + + // appCtx is the global Android App context. + appCtx AppContext +} diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go new file mode 100644 index 0000000..7916712 --- /dev/null +++ b/libtailscale/interfaces.go @@ -0,0 +1,151 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import _ "golang.org/x/mobile/bind" + +// Start starts the application, storing state in the given dataDir and using +// the given appCtx. +func Start(dataDir string, appCtx AppContext) Application { + return start(dataDir, appCtx) +} + +// AppContext provides a context within which the Application is running. This +// context is a hook into functionality that's implemented on the Java side. +type AppContext interface { + // Log logs the given tag and logLine + Log(tag, logLine string) + + // EncryptToPref stores the given value to an encrypted preference at the + // given key. + EncryptToPref(key, value string) error + + // DecryptFromPref retrieves the given value from an encrypted preference + // at the given key, or returns empty string if unset. + DecryptFromPref(key string) (string, error) + + // GetOSVersion gets the Android version. + GetOSVersion() (string, error) + + // GetModelName gets the Android device's model name. + GetModelName() (string, error) + + // IsPlayVersion reports whether this is the Google Play version of the app + // (as opposed to F-droid/sideloaded). + IsPlayVersion() bool + + // IsChromeOS reports whether we're on a ChromeOS device. + IsChromeOS() (bool, error) + + // GetInterfacesAsString gets a string representation of all network + // interfaces. + GetInterfacesAsString() (string, error) + + // GetPlatformDNSConfig gets a string representation of the current DNS + // configuration. + GetPlatformDNSConfig() string +} + +// IPNService corresponds to our IPNService in Java. +type IPNService interface { + // ID returns the unique ID of this instance of the IPNService. Every time + // we start a new IPN service, it should have a new ID. + ID() string + + // Protect protects socket identified by the given file descriptor from + // being captured by the VPN. The return value indicates whether or not the + // socket was successfully protected. + Protect(fd int32) bool + + // NewBuilder creates a new VPNServiceBuilder in preparation for starting + // the Android VPN. + NewBuilder() VPNServiceBuilder +} + +// VPNServiceBuilder corresponds to Android's VpnService.Builder. +type VPNServiceBuilder interface { + SetMTU(int) error + AddDNSServer(string) error + AddSearchDomain(string) error + AddRoute(string, int32) error + AddAddress(string, int32) error + Establish() (ParcelFileDescriptor, error) +} + +// ParcelFileDescriptor corresponds to Android's ParcelFileDescriptor. +type ParcelFileDescriptor interface { + Detach() (int32, error) +} + +// Application encapsulates the running Tailscale Application. There is only a +// single instance of Application per Android application. +type Application interface { + // CallLocalAPI provides a mechanism for calling Tailscale's HTTP localapi + // without having to call over the network. + CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) + + // WatchNotifications provides a mechanism for subscribing to ipn.Notify + // updates. The given NotificationCallback's OnNotify function is invoked + // on every new ipn.Notify message. The returned NotificationManager + // allows the watcher to stop watching notifications. + WatchNotifications(mask int, cb NotificationCallback) NotificationManager +} + +// LocalAPIResponse is a response to a localapi call, analogous to an http.Response. +type LocalAPIResponse interface { + StatusCode() int + BodyBytes() ([]byte, error) + BodyInputStream() InputStream +} + +// NotificationCallback is callback for receiving ipn.Notify messages. +type NotificationCallback interface { + OnNotify([]byte) error +} + +// NotificationManager provides a mechanism for a notification watcher to stop +// watching notifications. +type NotificationManager interface { + Stop() +} + +// InputStream provides an adapter between Java's InputStream and Go's +// io.Reader. +type InputStream interface { + Read() ([]byte, error) + Close() error +} + +// The below are global callbacks that allow the Java application to notify Go +// of various state changes. + +func OnVPNPrepared() { + notifyVPNPrepared() +} + +func RequestVPN(service IPNService) { + onVPNRequested <- service +} + +func ServiceDisconnect(service IPNService) { + onDisconnect <- service +} + +func OnActivityResult(reqCode, resCode int, idToken string) { + switch reqCode { + case requestSignin: + if resCode != resultOK { + onGoogleToken <- "" + break + } + onGoogleToken <- idToken + case requestPrepareVPN: + if resCode == resultOK { + notifyVPNPrepared() + } else { + notifyVPNClosed() + notifyVPNRevoked() + } + } +} diff --git a/libtailscale/localapi.go b/libtailscale/localapi.go new file mode 100644 index 0000000..6435eaa --- /dev/null +++ b/libtailscale/localapi.go @@ -0,0 +1,149 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "context" + "fmt" + "io" + "log" + "net" + "net/http" + "runtime/debug" + "sync" + "time" +) + +// CallLocalAPI calls the given endpoint on the local API using the given HTTP method +// optionally sending the given body. It returns a Response representing the +// result of the call and an error if the call could not be completed or the +// local API returned a status code in the 400 series or greater. +// Note - Response includes a response body available from the Body method, it +// is the caller's responsibility to close this. +func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in CallLocalAPI %s: %s", p, debug.Stack()) + panic(p) + } + }() + + app.ready.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(uint64(timeoutMillis)*uint64(time.Millisecond))) + defer cancel() + + if body != nil { + defer body.Close() + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, adaptInputStream(body)) + if err != nil { + return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err) + } + deadline, _ := ctx.Deadline() + pipeReader, pipeWriter := net.Pipe() + pipeReader.SetDeadline(deadline) + pipeWriter.SetDeadline(deadline) + + resp := &Response{ + headers: http.Header{}, + status: http.StatusOK, + bodyReader: pipeReader, + bodyWriter: pipeWriter, + startWritingBody: make(chan interface{}), + } + + go func() { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in CallLocalAPI.ServeHTTP %s: %s", p, debug.Stack()) + panic(p) + } + }() + + app.localAPIHandler.ServeHTTP(resp, req) + resp.Flush() + pipeWriter.Close() + }() + + select { + case <-resp.startWritingBody: + return resp, nil + case <-ctx.Done(): + return nil, fmt.Errorf("timeout for %s", endpoint) + } +} + +// Response represents the result of processing an localAPI request. +// On completion, the response body can be read out of the bodyWriter. +type Response struct { + headers http.Header + status int + bodyWriter net.Conn + bodyReader net.Conn + startWritingBody chan interface{} + startWritingBodyOnce sync.Once +} + +func (r *Response) Header() http.Header { + return r.headers +} + +// Write writes the data to the response body which an then be +// read out as a json object. +func (r *Response) Write(data []byte) (int, error) { + r.Flush() + if r.status == 0 { + r.WriteHeader(http.StatusOK) + } + return r.bodyWriter.Write(data) +} + +func (r *Response) WriteHeader(statusCode int) { + r.status = statusCode +} + +func (r *Response) Body() net.Conn { + return r.bodyReader +} + +func (r *Response) BodyBytes() ([]byte, error) { + return io.ReadAll(r.bodyReader) +} + +func (r *Response) BodyInputStream() InputStream { + return nil +} + +func (r *Response) StatusCode() int { + return r.status +} + +func (r *Response) Flush() { + r.startWritingBodyOnce.Do(func() { + close(r.startWritingBody) + }) +} + +func adaptInputStream(in InputStream) io.Reader { + if in == nil { + return nil + } + r, w := io.Pipe() + go func() { + defer w.Close() + for { + b, err := in.Read() + if err != nil { + log.Printf("error reading from inputstream: %s", err) + } + if b == nil { + return + } + w.Write(b) + } + }() + return r +} diff --git a/pkg/tailscale/log.go b/libtailscale/log.go similarity index 72% rename from pkg/tailscale/log.go rename to libtailscale/log.go index 407c1fc..95ab617 100644 --- a/pkg/tailscale/log.go +++ b/libtailscale/log.go @@ -1,7 +1,9 @@ -// Gratefully borrowed from Gio https://gioui.org/ -// SPDX-License-Identifier: MIT +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause -package main +// Gratefully borrowed from Gio UI https://gioui.org/ under MIT license + +package libtailscale /* #cgo LDFLAGS: -llog @@ -17,6 +19,7 @@ import ( "os" "path/filepath" "runtime" + "runtime/debug" "syscall" "unsafe" ) @@ -28,10 +31,12 @@ var ID = filepath.Base(os.Args[0]) var logTag = C.CString(ID) -func init() { +func initLogging(appCtx AppContext) { // Android's logcat already includes timestamps. log.SetFlags(log.Flags() &^ log.LstdFlags) - log.SetOutput(new(androidLogWriter)) + log.SetOutput(&androidLogWriter{ + appCtx: appCtx, + }) // Redirect stdout and stderr to the Android logger. logFd(os.Stdout.Fd()) @@ -39,23 +44,18 @@ func init() { } type androidLogWriter struct { - // buf has room for the maximum log line, plus a terminating '\0'. - buf [logLineLimit + 1]byte + appCtx AppContext } func (w *androidLogWriter) Write(data []byte) (int, error) { n := 0 for len(data) > 0 { msg := data - // Truncate the buffer, leaving space for the '\0'. - if max := len(w.buf) - 1; len(msg) > max { - msg = msg[:max] + // Truncate the buffer + if len(msg) > logLineLimit { + msg = msg[:logLineLimit] } - buf := w.buf[:len(msg)+1] - copy(buf, msg) - // Terminating '\0'. - buf[len(msg)] = 0 - C.__android_log_write(C.ANDROID_LOG_INFO, logTag, (*C.char)(unsafe.Pointer(&buf[0]))) + w.appCtx.Log(ID, string(msg)) n += len(msg) data = data[len(msg):] } @@ -71,6 +71,13 @@ func logFd(fd uintptr) { panic(err) } go func() { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in logFd %s: %s", p, debug.Stack()) + panic(p) + } + }() + lineBuf := bufio.NewReaderSize(r, logLineLimit) // The buffer to pass to C, including the terminating '\0'. buf := make([]byte, lineBuf.Size()+1) diff --git a/pkg/tailscale/multitun.go b/libtailscale/multitun.go similarity index 90% rename from pkg/tailscale/multitun.go rename to libtailscale/multitun.go index 4531f88..3fa89ef 100644 --- a/pkg/tailscale/multitun.go +++ b/libtailscale/multitun.go @@ -1,10 +1,12 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -package main +package libtailscale import ( + "log" "os" + "runtime/debug" "github.com/tailscale/wireguard-go/tun" ) @@ -80,6 +82,13 @@ func newTUNDevices() *multiTUN { } func (d *multiTUN) run() { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in multiTUN.run %s: %s", p, debug.Stack()) + panic(p) + } + }() + var devices []*tunDevice // readDone is the readDone channel of the device being read from. var readDone chan struct{} @@ -161,6 +170,13 @@ func (d *multiTUN) run() { } func (d *multiTUN) readFrom(dev *tunDevice) { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in multiTUN.readFrom %s: %s", p, debug.Stack()) + panic(p) + } + }() + defer func() { dev.readDone <- struct{}{} }() @@ -188,6 +204,13 @@ func (d *multiTUN) readFrom(dev *tunDevice) { } func (d *multiTUN) runDevice(dev *tunDevice) { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in multiTUN.runDevice %s: %s", p, debug.Stack()) + panic(p) + } + }() + defer func() { // The documentation for https://developer.android.com/reference/android/net/VpnService.Builder#establish() // states that "Therefore, after draining the old file @@ -199,6 +222,12 @@ func (d *multiTUN) runDevice(dev *tunDevice) { }() // Pump device events. go func() { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in multiTUN.readFrom.events %s: %s", p, debug.Stack()) + panic(p) + } + }() for { select { case e := <-dev.dev.Events(): diff --git a/pkg/tailscale/net.go b/libtailscale/net.go similarity index 52% rename from pkg/tailscale/net.go rename to libtailscale/net.go index d2b14c9..9186f27 100644 --- a/pkg/tailscale/net.go +++ b/libtailscale/net.go @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -package main +package libtailscale import ( "errors" @@ -10,9 +10,9 @@ import ( "net" "net/netip" "reflect" + "runtime/debug" "strings" - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" "github.com/tailscale/wireguard-go/tun" "golang.org/x/sys/unix" "inet.af/netaddr" @@ -22,8 +22,6 @@ import ( "tailscale.com/wgengine/router" ) -import "C" - // errVPNNotPrepared is used when VPNService.Builder.establish returns // null, either because the VPNService is not yet prepared or because // VPN status was revoked. @@ -37,16 +35,9 @@ var errMultipleUsers = errors.New("VPN cannot be created on this device due to a // Report interfaces in the device in net.Interface format. func (a *App) getInterfaces() ([]interfaces.Interface, error) { - var ifaceString string - err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - cls := jnipkg.GetObjectClass(env, a.appCtx) - m := jnipkg.GetMethodID(env, cls, "getInterfacesAsString", "()Ljava/lang/String;") - n, err := jnipkg.CallObjectMethod(env, a.appCtx, m) - ifaceString = jnipkg.GoString(env, jnipkg.String(n)) - return err - - }) var ifaces []interfaces.Interface + + ifaceString, err := a.appCtx.GetInterfacesAsString() if err != nil { return ifaces, err } @@ -125,7 +116,7 @@ var googleDNSServers = []netip.Addr{ netip.MustParseAddr("2001:4860:4860::8844"), } -func (b *backend) updateTUN(service jnipkg.Object, rcfg *router.Config, dcfg *dns.OSConfig) error { +func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.OSConfig) error { if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { return nil } @@ -141,123 +132,75 @@ func (b *backend) updateTUN(service jnipkg.Object, rcfg *router.Config, dcfg *dn if len(rcfg.LocalAddrs) == 0 { return nil } - err := jnipkg.Do(b.jvm, func(env *jnipkg.Env) error { - cls := jnipkg.GetObjectClass(env, service) - // Construct a VPNService.Builder. IPNService.newBuilder calls - // setConfigureIntent, and allowFamily for both IPv4 and IPv6. - m := jnipkg.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;") - builder, err := jnipkg.CallObjectMethod(env, service, m) - if err != nil { - return fmt.Errorf("IPNService.newBuilder: %v", err) - } - bcls := jnipkg.GetObjectClass(env, builder) + builder := service.NewBuilder() - // builder.setMtu. - setMtu := jnipkg.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;") - const mtu = defaultMTU - if _, err := jnipkg.CallObjectMethod(env, builder, setMtu, jnipkg.Value(mtu)); err != nil { - return fmt.Errorf("VpnService.Builder.setMtu: %v", err) + if err := builder.SetMTU(defaultMTU); err != nil { + return err + } + if dcfg != nil { + nameservers := dcfg.Nameservers + if b.avoidEmptyDNS && len(nameservers) == 0 { + nameservers = googleDNSServers } - - // builder.addDnsServer - addDnsServer := jnipkg.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;") - // builder.addSearchDomain. - addSearchDomain := jnipkg.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;") - if dcfg != nil { - nameservers := dcfg.Nameservers - if b.avoidEmptyDNS && len(nameservers) == 0 { - nameservers = googleDNSServers - } - for _, dns := range nameservers { - _, err = jnipkg.CallObjectMethod(env, - builder, - addDnsServer, - jnipkg.Value(jnipkg.JavaString(env, dns.String())), - ) - if err != nil { - return fmt.Errorf("VpnService.Builder.addDnsServer(%v): %v", dns, err) - } - } - - for _, dom := range dcfg.SearchDomains { - _, err = jnipkg.CallObjectMethod(env, - builder, - addSearchDomain, - jnipkg.Value(jnipkg.JavaString(env, dom.WithoutTrailingDot())), - ) - if err != nil { - return fmt.Errorf("VpnService.Builder.addSearchDomain(%v): %v", dom, err) - } + for _, dns := range nameservers { + if err := builder.AddDNSServer(dns.String()); err != nil { + return err } } - - // builder.addRoute. - addRoute := jnipkg.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;") - for _, route := range rcfg.Routes { - // Normalize route address; Builder.addRoute does not accept non-zero masked bits. - route = route.Masked() - _, err = jnipkg.CallObjectMethod(env, - builder, - addRoute, - jnipkg.Value(jnipkg.JavaString(env, route.Addr().String())), - jnipkg.Value(route.Bits()), - ) - if err != nil { - return fmt.Errorf("VpnService.Builder.addRoute(%v): %v", route, err) + for _, dom := range dcfg.SearchDomains { + if err := builder.AddSearchDomain(dom.WithoutTrailingDot()); err != nil { + return err } } + } - // builder.addAddress. - addAddress := jnipkg.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;") - for _, addr := range rcfg.LocalAddrs { - _, err = jnipkg.CallObjectMethod(env, - builder, - addAddress, - jnipkg.Value(jnipkg.JavaString(env, addr.Addr().String())), - jnipkg.Value(addr.Bits()), - ) - if err != nil { - return fmt.Errorf("VpnService.Builder.addAddress(%v): %v", addr, err) - } + for _, route := range rcfg.Routes { + // Normalize route address; Builder.addRoute does not accept non-zero masked bits. + route = route.Masked() + if err := builder.AddRoute(route.Addr().String(), int32(route.Bits())); err != nil { + return err } + } - // builder.establish. - establish := jnipkg.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;") - parcelFD, err := jnipkg.CallObjectMethod(env, builder, establish) - if err != nil { - if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") { - return errMultipleUsers - } - return fmt.Errorf("VpnService.Builder.establish: %v", err) - } - if parcelFD == 0 { - return errVPNNotPrepared + for _, addr := range rcfg.LocalAddrs { + if err := builder.AddAddress(addr.Addr().String(), int32(addr.Bits())); err != nil { + return err } + } - // detachFd. - parcelCls := jnipkg.GetObjectClass(env, parcelFD) - detachFd := jnipkg.GetMethodID(env, parcelCls, "detachFd", "()I") - tunFD, err := jnipkg.CallIntMethod(env, parcelFD, detachFd) - if err != nil { - return fmt.Errorf("detachFd: %v", err) + parcelFD, err := builder.Establish() + if err != nil { + if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") { + return errMultipleUsers } + return fmt.Errorf("VpnService.Builder.establish: %v", err) + } - // Create TUN device. - tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD)) - if err != nil { - unix.Close(int(tunFD)) - return err - } + if parcelFD == nil { + return errVPNNotPrepared + } - b.devices.add(tunDev) + // detachFd. + tunFD, err := parcelFD.Detach() + if err != nil { + return fmt.Errorf("detachFd: %v", err) + } - return nil - }) + // Create TUN device. + tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD)) if err != nil { - b.lastCfg = nil - b.CloseTUNs() + unix.Close(int(tunFD)) return err } + + b.devices.add(tunDev) + + // TODO(oxtoacart): figure out what to do with this + // if err != nil { + // b.lastCfg = nil + // b.CloseTUNs() + // return err + // } b.lastCfg = rcfg b.lastDNSCfg = dcfg return nil @@ -270,6 +213,13 @@ func (b *backend) CloseTUNs() { } func (b *backend) NetworkChanged() { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in NetworkChanged %s: %s", p, debug.Stack()) + panic(p) + } + }() + if b.sys != nil { if nm, ok := b.sys.NetMon.GetOK(); ok { nm.InjectEvent() @@ -285,7 +235,7 @@ func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) { // DNS config are lacking, and almost all Android phones use Google // services anyway, so it's a reasonable default: it's an ecosystem the // user has selected by having an Android device. - if len(ret.Nameservers) == 0 && googleSignInEnabled() { + if len(ret.Nameservers) == 0 && b.appCtx.IsPlayVersion() { log.Printf("getDNSBaseConfig: none found; falling back to Google public DNS") ret.Nameservers = append(ret.Nameservers, googleDNSServers...) } @@ -320,25 +270,7 @@ func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) { } func (b *backend) getPlatformDNSConfig() string { - var baseConfig string - err := jnipkg.Do(b.jvm, func(env *jnipkg.Env) error { - cls := jnipkg.GetObjectClass(env, b.appCtx) - m := jnipkg.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;") - dns, err := jnipkg.CallObjectMethod(env, b.appCtx, m) - if err != nil { - return fmt.Errorf("getDnsConfigObj: %v", err) - } - dnsCls := jnipkg.GetObjectClass(env, dns) - m = jnipkg.GetMethodID(env, dnsCls, "getDnsConfigAsString", "()Ljava/lang/String;") - n, err := jnipkg.CallObjectMethod(env, dns, m) - baseConfig = jnipkg.GoString(env, jnipkg.String(n)) - return err - }) - if err != nil { - log.Printf("getPlatformDNSConfig JNI: %v", err) - return "" - } - return baseConfig + return b.appCtx.GetPlatformDNSConfig() } func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error { diff --git a/libtailscale/notifier.go b/libtailscale/notifier.go new file mode 100644 index 0000000..67ade60 --- /dev/null +++ b/libtailscale/notifier.go @@ -0,0 +1,48 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "context" + "encoding/json" + "log" + "runtime/debug" + + "tailscale.com/ipn" +) + +func (app *App) WatchNotifications(mask int, cb NotificationCallback) NotificationManager { + app.ready.Wait() + + ctx, cancel := context.WithCancel(context.Background()) + go app.backend.WatchNotifications(ctx, ipn.NotifyWatchOpt(mask), func() {}, func(notify *ipn.Notify) bool { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in WatchNotifications %s: %s", p, debug.Stack()) + panic(p) + } + }() + + b, err := json.Marshal(notify) + if err != nil { + log.Printf("error: WatchNotifications: marshal notify: %s", err) + return true + } + err = cb.OnNotify(b) + if err != nil { + log.Printf("error: WatchNotifications: OnNotify: %s", err) + return true + } + return true + }) + return ¬ificationManager{cancel} +} + +type notificationManager struct { + cancel func() +} + +func (nm *notificationManager) Stop() { + nm.cancel() +} diff --git a/pkg/tailscale/store.go b/libtailscale/store.go similarity index 54% rename from pkg/tailscale/store.go rename to libtailscale/store.go index 1bc6b43..3496b5d 100644 --- a/pkg/tailscale/store.go +++ b/libtailscale/store.go @@ -1,47 +1,26 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -package main +package libtailscale import ( "encoding/base64" "tailscale.com/ipn" - - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" ) // stateStore is the Go interface for a persistent storage // backend by androidx.security.crypto.EncryptedSharedPreferences (see // App.java). type stateStore struct { - jvm *jnipkg.JVM // appCtx is the global Android app context. - appCtx jnipkg.Object - - // Cached method ids on appCtx. - encrypt jnipkg.MethodID - decrypt jnipkg.MethodID + appCtx AppContext } -func newStateStore(jvm *jnipkg.JVM, appCtx jnipkg.Object) *stateStore { - s := &stateStore{ - jvm: jvm, +func newStateStore(appCtx AppContext) *stateStore { + return &stateStore{ appCtx: appCtx, } - jnipkg.Do(jvm, func(env *jnipkg.Env) error { - appCls := jnipkg.GetObjectClass(env, appCtx) - s.encrypt = jnipkg.GetMethodID( - env, appCls, - "encryptToPref", "(Ljava/lang/String;Ljava/lang/String;)V", - ) - s.decrypt = jnipkg.GetMethodID( - env, appCls, - "decryptFromPref", "(Ljava/lang/String;)Ljava/lang/String;", - ) - return nil - }) - return s } func prefKeyFor(id ipn.StateKey) string { @@ -99,35 +78,17 @@ func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error { } func (s *stateStore) read(key string) ([]byte, error) { - var data []byte - err := jnipkg.Do(s.jvm, func(env *jnipkg.Env) error { - jfile := jnipkg.JavaString(env, key) - plain, err := jnipkg.CallObjectMethod(env, s.appCtx, s.decrypt, - jnipkg.Value(jfile)) - if err != nil { - return err - } - b64 := jnipkg.GoString(env, jnipkg.String(plain)) - if b64 == "" { - return nil - } - data, err = base64.RawStdEncoding.DecodeString(b64) - return err - }) - return data, err + b64, err := s.appCtx.DecryptFromPref(key) + if err != nil { + return nil, err + } + if b64 == "" { + return nil, nil + } + return base64.RawStdEncoding.DecodeString(b64) } func (s *stateStore) write(key string, value []byte) error { bs64 := base64.RawStdEncoding.EncodeToString(value) - err := jnipkg.Do(s.jvm, func(env *jnipkg.Env) error { - jfile := jnipkg.JavaString(env, key) - jplain := jnipkg.JavaString(env, bs64) - err := jnipkg.CallVoidMethod(env, s.appCtx, s.encrypt, - jnipkg.Value(jfile), jnipkg.Value(jplain)) - if err != nil { - return err - } - return nil - }) - return err + return s.appCtx.EncryptToPref(key, bs64) } diff --git a/pkg/tailscale/tailscale.go b/libtailscale/tailscale.go similarity index 57% rename from pkg/tailscale/tailscale.go rename to libtailscale/tailscale.go index 17d8fe4..4d5d14f 100644 --- a/pkg/tailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -1,17 +1,16 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -package main +package libtailscale import ( "context" "log" "net/http" "path/filepath" + "runtime/debug" "time" - "unsafe" - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" "tailscale.com/logpolicy" "tailscale.com/logtail" "tailscale.com/logtail/filch" @@ -23,13 +22,6 @@ import ( "tailscale.com/util/must" ) -import "C" - -var ( - // googleClass is a global reference to the com.tailscale.ipn.Google class. - googleClass jnipkg.Class -) - const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go const ( @@ -42,25 +34,30 @@ type ConnectEvent struct { Enable bool } -func main() { +func newApp(dataDir string, appCtx AppContext) Application { a := &App{ - jvm: (*jnipkg.JVM)(unsafe.Pointer(javaVM())), - appCtx: jnipkg.Object(appContext()), + dataDir: dataDir, + appCtx: appCtx, } + a.ready.Add(1) - err := a.loadJNIGlobalClassRefs() - if err != nil { - fatalErr(err) - } - - a.store = newStateStore(a.jvm, a.appCtx) + a.store = newStateStore(a.appCtx) interfaces.RegisterInterfaceGetter(a.getInterfaces) go func() { + defer func() { + if p := recover(); p != nil { + log.Printf("panic in runBackend %s: %s", p, debug.Stack()) + panic(p) + } + }() + ctx := context.Background() if err := a.runBackend(ctx); err != nil { fatalErr(err) } }() + + return a } func fatalErr(err error) { @@ -71,14 +68,7 @@ func fatalErr(err error) { // osVersion returns android.os.Build.VERSION.RELEASE. " [nogoogle]" is appended // if Google Play services are not compiled in. func (a *App) osVersion() string { - var version string - err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - cls := jnipkg.GetObjectClass(env, a.appCtx) - m := jnipkg.GetMethodID(env, cls, "getOSVersion", "()Ljava/lang/String;") - n, err := jnipkg.CallObjectMethod(env, a.appCtx, m) - version = jnipkg.GoString(env, jnipkg.String(n)) - return err - }) + version, err := a.appCtx.GetOSVersion() if err != nil { panic(err) } @@ -88,14 +78,7 @@ func (a *App) osVersion() string { // modelName return the MANUFACTURER + MODEL from // android.os.Build. func (a *App) modelName() string { - var model string - err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - cls := jnipkg.GetObjectClass(env, a.appCtx) - m := jnipkg.GetMethodID(env, cls, "getModelName", "()Ljava/lang/String;") - n, err := jnipkg.CallObjectMethod(env, a.appCtx, m) - model = jnipkg.GoString(env, jnipkg.String(n)) - return err - }) + model, err := a.appCtx.GetModelName() if err != nil { panic(err) } @@ -103,37 +86,11 @@ func (a *App) modelName() string { } func (a *App) isChromeOS() bool { - var chromeOS bool - err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - cls := jnipkg.GetObjectClass(env, a.appCtx) - m := jnipkg.GetMethodID(env, cls, "isChromeOS", "()Z") - b, err := jnipkg.CallBooleanMethod(env, a.appCtx, m) - chromeOS = b - return err - }) + isChromeOS, err := a.appCtx.IsChromeOS() if err != nil { panic(err) } - return chromeOS -} - -func googleSignInEnabled() bool { - return googleClass != 0 -} - -// Loads the global JNI class references. Failures here are fatal if the -// class ref is required for the app to function. -func (a *App) loadJNIGlobalClassRefs() error { - return jnipkg.Do(a.jvm, func(env *jnipkg.Env) error { - loader := jnipkg.ClassLoaderFor(env, a.appCtx) - cl, err := jnipkg.LoadClass(env, loader, "com.tailscale.ipn.Google") - if err != nil { - // Ignore load errors; the Google class is not included in F-Droid builds. - return nil - } - googleClass = jnipkg.Class(jnipkg.NewGlobalRef(env, jnipkg.Object(cl))) - return nil - }) + return isChromeOS } // SetupLogs sets up remote logging. diff --git a/pkg/jni/jnipkg.go b/pkg/jni/jnipkg.go deleted file mode 100644 index d32db06..0000000 --- a/pkg/jni/jnipkg.go +++ /dev/null @@ -1,506 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -// Package jni implements various helper functions for communicating with the Android JVM -// though JNI. -package jnipkg - -import ( - "errors" - "fmt" - "reflect" - "runtime" - "sync" - "unicode/utf16" - "unsafe" -) - -/* -#cgo CFLAGS: -Wall - -#include -#include - -static jint jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) { - return (*vm)->AttachCurrentThread(vm, p_env, thr_args); -} - -static jint jni_DetachCurrentThread(JavaVM *vm) { - return (*vm)->DetachCurrentThread(vm); -} - -static jint jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version) { - return (*vm)->GetEnv(vm, (void **)env, version); -} - -static jclass jni_FindClass(JNIEnv *env, const char *name) { - return (*env)->FindClass(env, name); -} - -static jthrowable jni_ExceptionOccurred(JNIEnv *env) { - return (*env)->ExceptionOccurred(env); -} - -static void jni_ExceptionClear(JNIEnv *env) { - (*env)->ExceptionClear(env); -} - -static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) { - return (*env)->GetObjectClass(env, obj); -} - -static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { - return (*env)->GetMethodID(env, clazz, name, sig); -} - -static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { - return (*env)->GetStaticMethodID(env, clazz, name, sig); -} - -static jsize jni_GetStringLength(JNIEnv *env, jstring str) { - return (*env)->GetStringLength(env, str); -} - -static const jchar *jni_GetStringChars(JNIEnv *env, jstring str) { - return (*env)->GetStringChars(env, str, NULL); -} - -static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) { - return (*env)->NewString(env, unicodeChars, len); -} - -static jboolean jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2) { - return (*env)->IsSameObject(env, ref1, ref2); -} - -static jobject jni_NewGlobalRef(JNIEnv *env, jobject obj) { - return (*env)->NewGlobalRef(env, obj); -} - -static void jni_DeleteGlobalRef(JNIEnv *env, jobject obj) { - (*env)->DeleteGlobalRef(env, obj); -} - -static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { - (*env)->CallStaticVoidMethodA(env, cls, method, args); -} - -static jint jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { - return (*env)->CallStaticIntMethodA(env, cls, method, args); -} - -static jobject jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { - return (*env)->CallStaticObjectMethodA(env, cls, method, args); -} - -static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { - return (*env)->CallObjectMethodA(env, obj, method, args); -} - -static jboolean jni_CallBooleanMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { - return (*env)->CallBooleanMethodA(env, obj, method, args); -} - -static jint jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { - return (*env)->CallIntMethodA(env, obj, method, args); -} - -static void jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { - (*env)->CallVoidMethodA(env, obj, method, args); -} - -static jbyteArray jni_NewByteArray(JNIEnv *env, jsize length) { - return (*env)->NewByteArray(env, length); -} - -static jboolean *jni_GetBooleanArrayElements(JNIEnv *env, jbooleanArray arr) { - return (*env)->GetBooleanArrayElements(env, arr, NULL); -} - -static void jni_ReleaseBooleanArrayElements(JNIEnv *env, jbooleanArray arr, jboolean *elems, jint mode) { - (*env)->ReleaseBooleanArrayElements(env, arr, elems, mode); -} - -static jbyte *jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr) { - return (*env)->GetByteArrayElements(env, arr, NULL); -} - -static jint *jni_GetIntArrayElements(JNIEnv *env, jintArray arr) { - return (*env)->GetIntArrayElements(env, arr, NULL); -} - -static void jni_ReleaseIntArrayElements(JNIEnv *env, jintArray arr, jint *elems, jint mode) { - (*env)->ReleaseIntArrayElements(env, arr, elems, mode); -} - -static jlong *jni_GetLongArrayElements(JNIEnv *env, jlongArray arr) { - return (*env)->GetLongArrayElements(env, arr, NULL); -} - -static void jni_ReleaseLongArrayElements(JNIEnv *env, jlongArray arr, jlong *elems, jint mode) { - (*env)->ReleaseLongArrayElements(env, arr, elems, mode); -} - -static void jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode) { - (*env)->ReleaseByteArrayElements(env, arr, elems, mode); -} - -static jsize jni_GetArrayLength(JNIEnv *env, jarray arr) { - return (*env)->GetArrayLength(env, arr); -} - -static void jni_DeleteLocalRef(JNIEnv *env, jobject localRef) { - return (*env)->DeleteLocalRef(env, localRef); -} - -static jobject jni_GetObjectArrayElement(JNIEnv *env, jobjectArray array, jsize index) { - return (*env)->GetObjectArrayElement(env, array, index); -} - -static jboolean jni_IsInstanceOf(JNIEnv *env, jobject obj, jclass clazz) { - return (*env)->IsInstanceOf(env, obj, clazz); -} - -static jint jni_GetJavaVM(JNIEnv *env, JavaVM **jvm) { - return (*env)->GetJavaVM(env, jvm); -} -*/ -import "C" - -type JVM C.JavaVM - -type Env C.JNIEnv - -type ( - Class C.jclass - Object C.jobject - MethodID C.jmethodID - String C.jstring - ByteArray C.jbyteArray - ObjectArray C.jobjectArray - BooleanArray C.jbooleanArray - LongArray C.jlongArray - IntArray C.jintArray - Boolean C.jboolean - Value uint64 // All JNI types fit into 64-bits. -) - -// Cached class handles. -var classes struct { - once sync.Once - stringClass, integerClass Class - - integerIntValue MethodID -} - -func env(e *Env) *C.JNIEnv { - return (*C.JNIEnv)(unsafe.Pointer(e)) -} - -func javavm(vm *JVM) *C.JavaVM { - return (*C.JavaVM)(unsafe.Pointer(vm)) -} - -// Do invokes a function with a temporary JVM environment. The -// environment is not valid after the function returns. -func Do(vm *JVM, f func(env *Env) error) error { - runtime.LockOSThread() - defer runtime.UnlockOSThread() - var env *C.JNIEnv - if res := C.jni_GetEnv(javavm(vm), &env, C.JNI_VERSION_1_6); res != C.JNI_OK { - if res != C.JNI_EDETACHED { - panic(fmt.Errorf("JNI GetEnv failed with error %d", res)) - } - if C.jni_AttachCurrentThread(javavm(vm), &env, nil) != C.JNI_OK { - panic(errors.New("runInJVM: AttachCurrentThread failed")) - } - defer C.jni_DetachCurrentThread(javavm(vm)) - } - - return f((*Env)(unsafe.Pointer(env))) -} - -func Bool(b bool) Boolean { - if b { - return C.JNI_TRUE - } - return C.JNI_FALSE -} - -func varArgs(args []Value) *C.jvalue { - if len(args) == 0 { - return nil - } - return (*C.jvalue)(unsafe.Pointer(&args[0])) -} - -func IsSameObject(e *Env, ref1, ref2 Object) bool { - same := C.jni_IsSameObject(env(e), C.jobject(ref1), C.jobject(ref2)) - return same == C.JNI_TRUE -} - -func CallStaticIntMethod(e *Env, cls Class, method MethodID, args ...Value) (int, error) { - res := C.jni_CallStaticIntMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args)) - return int(res), exception(e) -} - -func CallStaticVoidMethod(e *Env, cls Class, method MethodID, args ...Value) error { - C.jni_CallStaticVoidMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args)) - return exception(e) -} - -func CallVoidMethod(e *Env, obj Object, method MethodID, args ...Value) error { - C.jni_CallVoidMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) - return exception(e) -} - -func CallStaticObjectMethod(e *Env, cls Class, method MethodID, args ...Value) (Object, error) { - res := C.jni_CallStaticObjectMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args)) - return Object(res), exception(e) -} - -func CallObjectMethod(e *Env, obj Object, method MethodID, args ...Value) (Object, error) { - res := C.jni_CallObjectMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) - return Object(res), exception(e) -} - -func CallBooleanMethod(e *Env, obj Object, method MethodID, args ...Value) (bool, error) { - res := C.jni_CallBooleanMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) - return res == C.JNI_TRUE, exception(e) -} - -func CallIntMethod(e *Env, obj Object, method MethodID, args ...Value) (int32, error) { - res := C.jni_CallIntMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) - return int32(res), exception(e) -} - -func GetArrayLength(e *Env, jarr ByteArray) int { - size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) - return int(size) -} - -// GetByteArrayElements returns the contents of the byte array. -func GetByteArrayElements(e *Env, jarr ByteArray) []byte { - if jarr == 0 { - return nil - } - size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) - elems := C.jni_GetByteArrayElements(env(e), C.jbyteArray(jarr)) - defer C.jni_ReleaseByteArrayElements(env(e), C.jbyteArray(jarr), elems, 0) - backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:size:size] - s := make([]byte, len(backing)) - copy(s, backing) - return s -} - -// GetBooleanArrayElements returns the contents of the boolean array. -func GetBooleanArrayElements(e *Env, jarr BooleanArray) []bool { - if jarr == 0 { - return nil - } - size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) - elems := C.jni_GetBooleanArrayElements(env(e), C.jbooleanArray(jarr)) - defer C.jni_ReleaseBooleanArrayElements(env(e), C.jbooleanArray(jarr), elems, 0) - backing := (*(*[1 << 30]C.jboolean)(unsafe.Pointer(elems)))[:size:size] - r := make([]bool, len(backing)) - for i, b := range backing { - r[i] = b == C.JNI_TRUE - } - return r -} - -// GetStringArrayElements returns the contents of the String array. -func GetStringArrayElements(e *Env, jarr ObjectArray) []string { - var strings []string - iterateObjectArray(e, jarr, func(e *Env, idx int, item Object) { - s := GoString(e, String(item)) - strings = append(strings, s) - }) - return strings -} - -// GetIntArrayElements returns the contents of the int array. -func GetIntArrayElements(e *Env, jarr IntArray) []int { - if jarr == 0 { - return nil - } - size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) - elems := C.jni_GetIntArrayElements(env(e), C.jintArray(jarr)) - defer C.jni_ReleaseIntArrayElements(env(e), C.jintArray(jarr), elems, 0) - backing := (*(*[1 << 27]C.jint)(unsafe.Pointer(elems)))[:size:size] - r := make([]int, len(backing)) - for i, l := range backing { - r[i] = int(l) - } - return r -} - -// GetLongArrayElements returns the contents of the long array. -func GetLongArrayElements(e *Env, jarr LongArray) []int64 { - if jarr == 0 { - return nil - } - size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) - elems := C.jni_GetLongArrayElements(env(e), C.jlongArray(jarr)) - defer C.jni_ReleaseLongArrayElements(env(e), C.jlongArray(jarr), elems, 0) - backing := (*(*[1 << 27]C.jlong)(unsafe.Pointer(elems)))[:size:size] - r := make([]int64, len(backing)) - for i, l := range backing { - r[i] = int64(l) - } - return r -} - -func iterateObjectArray(e *Env, jarr ObjectArray, f func(e *Env, idx int, item Object)) { - if jarr == 0 { - return - } - size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) - for i := 0; i < int(size); i++ { - item := C.jni_GetObjectArrayElement(env(e), C.jobjectArray(jarr), C.jint(i)) - f(e, i, Object(item)) - C.jni_DeleteLocalRef(env(e), item) - } -} - -// NewByteArray allocates a Java byte array with the content. It -// panics if the allocation fails. -func NewByteArray(e *Env, content []byte) ByteArray { - jarr := C.jni_NewByteArray(env(e), C.jsize(len(content))) - if jarr == 0 { - panic(fmt.Errorf("jni: NewByteArray(%d) failed", len(content))) - } - elems := C.jni_GetByteArrayElements(env(e), jarr) - defer C.jni_ReleaseByteArrayElements(env(e), jarr, elems, 0) - backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:len(content):len(content)] - copy(backing, content) - return ByteArray(jarr) -} - -// ClassLoader returns a reference to the Java ClassLoader associated -// with obj. -func ClassLoaderFor(e *Env, obj Object) Object { - cls := GetObjectClass(e, obj) - getClassLoader := GetMethodID(e, cls, "getClassLoader", "()Ljava/lang/ClassLoader;") - clsLoader, err := CallObjectMethod(e, Object(obj), getClassLoader) - if err != nil { - // Class.getClassLoader should never fail. - panic(err) - } - return Object(clsLoader) -} - -// LoadClass invokes the underlying ClassLoader's loadClass method and -// returns the class. -func LoadClass(e *Env, loader Object, class string) (Class, error) { - cls := GetObjectClass(e, loader) - loadClass := GetMethodID(e, cls, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;") - name := JavaString(e, class) - loaded, err := CallObjectMethod(e, loader, loadClass, Value(name)) - if err != nil { - return 0, err - } - return Class(loaded), exception(e) -} - -// exception returns an error corresponding to the pending -// exception, and clears it. exceptionError returns nil if no -// exception is pending. -func exception(e *Env) error { - thr := C.jni_ExceptionOccurred(env(e)) - if thr == 0 { - return nil - } - C.jni_ExceptionClear(env(e)) - cls := GetObjectClass(e, Object(thr)) - toString := GetMethodID(e, cls, "toString", "()Ljava/lang/String;") - msg, err := CallObjectMethod(e, Object(thr), toString) - if err != nil { - return err - } - return errors.New(GoString(e, String(msg))) -} - -// GetObjectClass returns the Java Class for an Object. -func GetObjectClass(e *Env, obj Object) Class { - if obj == 0 { - panic("null object") - } - cls := C.jni_GetObjectClass(env(e), C.jobject(obj)) - if err := exception(e); err != nil { - // GetObjectClass should never fail. - panic(err) - } - return Class(cls) -} - -// GetStaticMethodID returns the id for a static method. It panics if the method -// wasn't found. -func GetStaticMethodID(e *Env, cls Class, name, signature string) MethodID { - mname := C.CString(name) - defer C.free(unsafe.Pointer(mname)) - msig := C.CString(signature) - defer C.free(unsafe.Pointer(msig)) - m := C.jni_GetStaticMethodID(env(e), C.jclass(cls), mname, msig) - if err := exception(e); err != nil { - panic(err) - } - return MethodID(m) -} - -// GetMethodID returns the id for a method. It panics if the method -// wasn't found. -func GetMethodID(e *Env, cls Class, name, signature string) MethodID { - mname := C.CString(name) - defer C.free(unsafe.Pointer(mname)) - msig := C.CString(signature) - defer C.free(unsafe.Pointer(msig)) - m := C.jni_GetMethodID(env(e), C.jclass(cls), mname, msig) - if err := exception(e); err != nil { - panic(err) - } - return MethodID(m) -} - -func NewGlobalRef(e *Env, obj Object) Object { - return Object(C.jni_NewGlobalRef(env(e), C.jobject(obj))) -} - -func DeleteGlobalRef(e *Env, obj Object) { - C.jni_DeleteGlobalRef(env(e), C.jobject(obj)) -} - -// JavaString converts the string to a JVM jstring. -func JavaString(e *Env, str string) String { - if str == "" { - return 0 - } - utf16Chars := utf16.Encode([]rune(str)) - res := C.jni_NewString(env(e), (*C.jchar)(unsafe.Pointer(&utf16Chars[0])), C.int(len(utf16Chars))) - return String(res) -} - -// GoString converts the JVM jstring to a Go string. -func GoString(e *Env, str String) string { - if str == 0 { - return "" - } - strlen := C.jni_GetStringLength(env(e), C.jstring(str)) - chars := C.jni_GetStringChars(env(e), C.jstring(str)) - var utf16Chars []uint16 - hdr := (*reflect.SliceHeader)(unsafe.Pointer(&utf16Chars)) - hdr.Data = uintptr(unsafe.Pointer(chars)) - hdr.Cap = int(strlen) - hdr.Len = int(strlen) - utf8 := utf16.Decode(utf16Chars) - return string(utf8) -} - -func GetJavaVM(e *Env) (*JVM, error) { - var jvm *C.JavaVM - result := C.jni_GetJavaVM(env(e), &jvm) - if result != C.JNI_OK { - return nil, errors.New("failed to get JavaVM") - } - return (*JVM)(jvm), nil -} diff --git a/pkg/localapiservice/localapi_test.go b/pkg/localapiservice/localapi_test.go deleted file mode 100644 index 97f0d06..0000000 --- a/pkg/localapiservice/localapi_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package localapiservice - -import ( - "context" - "io" - "net/http" - "testing" - "time" -) - -var ctx = context.Background() - -type BadStatusHandler struct{} - -func (b *BadStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) -} - -func TestBadStatus(t *testing.T) { - ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second)) - client := New(&BadStatusHandler{}) - defer cancel() - - _, err := client.Call(ctx, "POST", "test", nil) - - if err.Error() != "request failed with status code 400" { - t.Error("Expected bad status error, but got", err) - } -} - -type TimeoutHandler struct{} - -var successfulResponse = "successful response!" - -func (b *TimeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - time.Sleep(6 * time.Second) - w.Write([]byte(successfulResponse)) -} - -func TestTimeout(t *testing.T) { - ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second)) - client := New(&TimeoutHandler{}) - defer cancel() - - _, err := client.Call(ctx, "GET", "test", nil) - - if err.Error() != "timeout for test" { - t.Error("Expected timeout error, but got", err) - } -} - -type SuccessfulHandler struct{} - -func (b *SuccessfulHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(successfulResponse)) -} - -func TestSuccess(t *testing.T) { - ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second)) - client := New(&SuccessfulHandler{}) - defer cancel() - - w, err := client.Call(ctx, "GET", "test", nil) - - if err != nil { - t.Error("Expected no error, but got", err) - } - - report, err := io.ReadAll(w.Body()) - if string(report) != successfulResponse { - t.Error("Expected successful report, but got", report) - } -} diff --git a/pkg/localapiservice/localapiservice.go b/pkg/localapiservice/localapiservice.go deleted file mode 100644 index 6fa88f5..0000000 --- a/pkg/localapiservice/localapiservice.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package localapiservice - -import ( - "context" - "fmt" - "io" - "log" - "net" - "net/http" - "time" - - "tailscale.com/ipn/ipnlocal" -) - -type LocalAPIService struct { - h http.Handler -} - -func New(h http.Handler) *LocalAPIService { - return &LocalAPIService{h: h} -} - -// Call calls the given endpoint on the local API using the given HTTP method -// optionally sending the given body. It returns a Response representing the -// result of the call and an error if the call could not be completed or the -// local API returned a status code in the 400 series or greater. -// Note - Response includes a response body available from the Body method, it -// is the caller's responsibility to close this. -func (cl *LocalAPIService) Call(ctx context.Context, method, endpoint string, body io.Reader) (*Response, error) { - req, err := http.NewRequestWithContext(ctx, method, endpoint, body) - if err != nil { - return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err) - } - deadline, _ := ctx.Deadline() - pipeReader, pipeWriter := net.Pipe() - pipeReader.SetDeadline(deadline) - pipeWriter.SetDeadline(deadline) - - resp := &Response{ - headers: http.Header{}, - status: http.StatusOK, - bodyReader: pipeReader, - bodyWriter: pipeWriter, - startWritingBody: make(chan interface{}), - } - - go func() { - cl.h.ServeHTTP(resp, req) - resp.Flush() - pipeWriter.Close() - }() - - select { - case <-resp.startWritingBody: - if resp.StatusCode() >= 400 { - return resp, fmt.Errorf("request failed with status code %d", resp.StatusCode()) - } - return resp, nil - case <-ctx.Done(): - return nil, fmt.Errorf("timeout for %s", endpoint) - } -} - -func (s *LocalAPIService) GetBugReportID(ctx context.Context, bugReportChan chan<- string, fallbackLog string) { - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - r, err := s.Call(ctx, "POST", "/localapi/v0/bugreport", nil) - defer r.Body().Close() - - if err != nil { - log.Printf("get bug report: %s", err) - bugReportChan <- fallbackLog - return - } - logBytes, err := io.ReadAll(r.Body()) - if err != nil { - log.Printf("read bug report: %s", err) - bugReportChan <- fallbackLog - return - } - bugReportChan <- string(logBytes) -} - -func (s *LocalAPIService) Login(ctx context.Context, backend *ipnlocal.LocalBackend) { - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - r, err := s.Call(ctx, "POST", "/localapi/v0/login-interactive", nil) - defer r.Body().Close() - - if err != nil { - log.Printf("login: %s", err) - backend.StartLoginInteractive() - } -} - -func (s *LocalAPIService) Logout(ctx context.Context, backend *ipnlocal.LocalBackend) error { - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - r, err := s.Call(ctx, "POST", "/localapi/v0/logout", nil) - defer r.Body().Close() - - if err != nil { - log.Printf("logout: %s", err) - logoutctx, logoutcancel := context.WithTimeout(ctx, 5*time.Minute) - defer logoutcancel() - backend.Logout(logoutctx) - } - - return err -} diff --git a/pkg/localapiservice/localapishim.go b/pkg/localapiservice/localapishim.go deleted file mode 100644 index c16c081..0000000 --- a/pkg/localapiservice/localapishim.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package localapiservice - -import ( - "bytes" - "context" - "encoding/json" - "io" - "log" - "time" - "unsafe" - - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" -) - -// #include -import "C" - -// Shims the LocalApiClient class from the Kotlin side to the Go side's LocalAPIService. -var shim struct { - // localApiClient is a global reference to the com.tailscale.ipn.ui.localapi.LocalApiClient class. - clientClass jnipkg.Class - - // notifierClass is a global reference to the com.tailscale.ipn.ui.notifier.Notifier class. - notifierClass jnipkg.Class - - // Typically a shared LocalAPIService instance. - service *LocalAPIService - - backend *ipnlocal.LocalBackend - - busWatchers map[string]func() - - jvm *jnipkg.JVM -} - -//export Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest -func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest( - env *C.JNIEnv, - cls C.jclass, - jpath C.jstring, - jmethod C.jstring, - jbody C.jbyteArray, - jcookie C.jstring) { - - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - - // The API Path - pathRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jpath)) - pathStr := jnipkg.GoString(jenv, jnipkg.String(pathRef)) - defer jnipkg.DeleteGlobalRef(jenv, pathRef) - - // The HTTP verb - methodRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jmethod)) - methodStr := jnipkg.GoString(jenv, jnipkg.String(methodRef)) - defer jnipkg.DeleteGlobalRef(jenv, methodRef) - - // The body string. This is optional and may be empty. - bodyRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jbody)) - bodyArray := jnipkg.GetByteArrayElements(jenv, jnipkg.ByteArray(bodyRef)) - defer jnipkg.DeleteGlobalRef(jenv, bodyRef) - - resp := doLocalAPIRequest(pathStr, methodStr, bodyArray) - - jrespBody := jnipkg.NewByteArray(jenv, resp) - respBody := jnipkg.Value(jrespBody) - cookie := jnipkg.Value(jcookie) - onResponse := jnipkg.GetMethodID(jenv, shim.clientClass, "onResponse", "([BLjava/lang/String;)V") - - jnipkg.CallVoidMethod(jenv, jnipkg.Object(cls), onResponse, respBody, cookie) -} - -func doLocalAPIRequest(path string, method string, body []byte) []byte { - if shim.service == nil { - return []byte("{\"error\":\"Not Ready\"}") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - var reader io.Reader = nil - if len(body) > 0 { - reader = bytes.NewReader(body) - } - - r, err := shim.service.Call(ctx, method, path, reader) - defer r.Body().Close() - - if err != nil { - return []byte("{\"error\":\"" + err.Error() + "\"}") - } - respBytes, err := io.ReadAll(r.Body()) - if err != nil { - return []byte("{\"error\":\"" + err.Error() + "\"}") - } - return respBytes -} - -// Assign a localAPIService to our shim for handling incoming localapi requests from the Kotlin side. -func ConfigureShim(jvm *jnipkg.JVM, appCtx jnipkg.Object, s *LocalAPIService, b *ipnlocal.LocalBackend) { - shim.busWatchers = make(map[string]func()) - shim.service = s - shim.backend = b - - configureLocalApiJNIHandler(jvm, appCtx) - - // Let the Kotlin side know we're ready to handle requests. - jnipkg.Do(jvm, func(env *jnipkg.Env) error { - onReadyAPI := jnipkg.GetStaticMethodID(env, shim.clientClass, "onReady", "()V") - jnipkg.CallStaticVoidMethod(env, shim.clientClass, onReadyAPI) - - onNotifyNot := jnipkg.GetStaticMethodID(env, shim.notifierClass, "onReady", "()V") - jnipkg.CallStaticVoidMethod(env, shim.notifierClass, onNotifyNot) - - log.Printf("LocalAPI Shim ready") - return nil - }) -} - -// Loads the Kotlin-side LocalApiClient class and stores it in a global reference. -func configureLocalApiJNIHandler(jvm *jnipkg.JVM, appCtx jnipkg.Object) error { - shim.jvm = jvm - - return jnipkg.Do(jvm, func(env *jnipkg.Env) error { - loader := jnipkg.ClassLoaderFor(env, appCtx) - cl, err := jnipkg.LoadClass(env, loader, "com.tailscale.ipn.ui.localapi.LocalApiClient") - if err != nil { - return err - } - shim.clientClass = jnipkg.Class(jnipkg.NewGlobalRef(env, jnipkg.Object(cl))) - - cl, err = jnipkg.LoadClass(env, loader, "com.tailscale.ipn.ui.notifier.Notifier") - if err != nil { - return err - } - shim.notifierClass = jnipkg.Class(jnipkg.NewGlobalRef(env, jnipkg.Object(cl))) - - return nil - }) -} - -//export Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher -func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher( - env *C.JNIEnv, - cls C.jclass, - jsessionId C.jstring) { - - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - - sessionIdRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jsessionId)) - sessionId := jnipkg.GoString(jenv, jnipkg.String(sessionIdRef)) - defer jnipkg.DeleteGlobalRef(jenv, sessionIdRef) - - cancel := shim.busWatchers[sessionId] - if cancel != nil { - log.Printf("Deregistering app layer bus watcher with sessionid: %s", sessionId) - cancel() - delete(shim.busWatchers, sessionId) - } else { - log.Printf("Error: Could not find bus watcher with sessionid: %s", sessionId) - } -} - -//export Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher -func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher( - env *C.JNIEnv, - cls C.jclass, - jsessionId C.jstring, - jmask C.jint) { - - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - - sessionIdRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jsessionId)) - sessionId := jnipkg.GoString(jenv, jnipkg.String(sessionIdRef)) - defer jnipkg.DeleteGlobalRef(jenv, sessionIdRef) - - log.Printf("Registering app layer bus watcher with sessionid: %s", sessionId) - - ctx, cancel := context.WithCancel(context.Background()) - shim.busWatchers[sessionId] = cancel - opts := ipn.NotifyWatchOpt(jmask) - - shim.backend.WatchNotifications(ctx, opts, func() { - // onWatchAdded - }, func(roNotify *ipn.Notify) bool { - js, err := json.Marshal(roNotify) - if err != nil { - return true - } - jnipkg.Do(shim.jvm, func(env *jnipkg.Env) error { - jjson := jnipkg.JavaString(env, string(js)) - onNotify := jnipkg.GetMethodID(env, shim.notifierClass, "onNotify", "(Ljava/lang/String;Ljava/lang/String;)V") - jnipkg.CallVoidMethod(env, jnipkg.Object(cls), onNotify, jnipkg.Value(jjson), jnipkg.Value(jsessionId)) - return nil - }) - return true - }) - -} diff --git a/pkg/localapiservice/response.go b/pkg/localapiservice/response.go deleted file mode 100644 index 9e30ebc..0000000 --- a/pkg/localapiservice/response.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package localapiservice - -import ( - "net" - "net/http" - "sync" -) - -// Response represents the result of processing an localAPI request. -// On completion, the response body can be read out of the bodyWriter. -type Response struct { - headers http.Header - status int - bodyWriter net.Conn - bodyReader net.Conn - startWritingBody chan interface{} - startWritingBodyOnce sync.Once -} - -func (r *Response) Header() http.Header { - return r.headers -} - -// Write writes the data to the response body which an then be -// read out as a json object. -func (r *Response) Write(data []byte) (int, error) { - r.Flush() - if r.status == 0 { - r.WriteHeader(http.StatusOK) - } - return r.bodyWriter.Write(data) -} - -func (r *Response) WriteHeader(statusCode int) { - r.status = statusCode -} - -func (r *Response) Body() net.Conn { - return r.bodyReader -} - -func (r *Response) StatusCode() int { - return r.status -} - -func (r *Response) Flush() { - r.startWritingBodyOnce.Do(func() { - close(r.startWritingBody) - }) -} diff --git a/pkg/tailscale/app.go b/pkg/tailscale/app.go deleted file mode 100644 index e77f520..0000000 --- a/pkg/tailscale/app.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "os" - "path/filepath" - "sync" - "sync/atomic" - "unsafe" - - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" - "github.com/tailscale/tailscale-android/pkg/localapiservice" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/types/logid" -) - -// #include -import "C" - -type App struct { - jvm *jnipkg.JVM - // appCtx is a global reference to the com.tailscale.ipn.App instance. - appCtx jnipkg.Object - - store *stateStore - logIDPublicAtomic atomic.Pointer[logid.PublicID] - - localAPI *localapiservice.LocalAPIService - backend *ipnlocal.LocalBackend -} - -var android struct { - // mu protects all fields of this structure. However, once a - // non-nil jvm is returned from javaVM, all the other fields may - // be accessed unlocked. - mu sync.Mutex - jvm *jnipkg.JVM - - // appCtx is the global Android App context. - appCtx C.jobject -} - -func initJVM(env *C.JNIEnv, ctx C.jobject) { - android.mu.Lock() - defer android.mu.Unlock() - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - res, err := jnipkg.GetJavaVM(jenv) - if err != nil { - panic("eror: GetJavaVM failed") - } - android.jvm = res - android.appCtx = C.jobject(jnipkg.NewGlobalRef(jenv, jnipkg.Object(ctx))) -} - -//export Java_com_tailscale_ipn_App_initBackend -func Java_com_tailscale_ipn_App_initBackend(env *C.JNIEnv, class C.jclass, jdataDir C.jbyteArray, context C.jobject) { - initJVM(env, context) - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - dirBytes := jnipkg.GetByteArrayElements(jenv, jnipkg.ByteArray(jdataDir)) - if dirBytes == nil { - panic("runGoMain: GetByteArrayElements failed") - } - n := jnipkg.GetArrayLength(jenv, jnipkg.ByteArray(jdataDir)) - dataDir := C.GoStringN((*C.char)(unsafe.Pointer(&dirBytes[0])), C.int(n)) - - // Set XDG_CACHE_HOME to make os.UserCacheDir work. - if _, exists := os.LookupEnv("XDG_CACHE_HOME"); !exists { - cachePath := filepath.Join(dataDir, "cache") - os.Setenv("XDG_CACHE_HOME", cachePath) - } - // Set XDG_CONFIG_HOME to make os.UserConfigDir work. - if _, exists := os.LookupEnv("XDG_CONFIG_HOME"); !exists { - cfgPath := filepath.Join(dataDir, "config") - os.Setenv("XDG_CONFIG_HOME", cfgPath) - } - // Set HOME to make os.UserHomeDir work. - if _, exists := os.LookupEnv("HOME"); !exists { - os.Setenv("HOME", dataDir) - } - - dataDirChan <- dataDir - main() -} diff --git a/pkg/tailscale/callbacks.go b/pkg/tailscale/callbacks.go deleted file mode 100644 index 25f5eb9..0000000 --- a/pkg/tailscale/callbacks.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package main - -import ( - "sync" - "unsafe" - - jnipkg "github.com/tailscale/tailscale-android/pkg/jni" -) - -// #include -import "C" - -var ( - // onVPNPrepared is notified when VpnService.prepare succeeds. - onVPNPrepared = make(chan struct{}, 1) - // onVPNClosed is notified when VpnService.prepare fails, or when - // the a running VPN connection is closed. - onVPNClosed = make(chan struct{}, 1) - // onVPNRevoked is notified whenever the VPN service is revoked. - onVPNRevoked = make(chan struct{}, 1) - - // onVPNRequested receives global IPNService references when - // a VPN connection is requested. - onVPNRequested = make(chan jnipkg.Object) - // onDisconnect receives global IPNService references when - // disconnecting. - onDisconnect = make(chan jnipkg.Object) - - onConnect = make(chan ConnectEvent) - - // onGoogleToken receives google ID tokens. - onGoogleToken = make(chan string) - - // onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. - onDNSConfigChanged = make(chan struct{}, 1) -) - -const ( - // Request codes for Android callbacks. - // requestSignin is for Google Sign-In. - requestSignin C.jint = 1000 + iota - // requestPrepareVPN is for when Android's VpnService.prepare - // completes. - requestPrepareVPN -) - -// resultOK is Android's Activity.RESULT_OK. -const resultOK = -1 - -//export Java_com_tailscale_ipn_App_onVPNPrepared -func Java_com_tailscale_ipn_App_onVPNPrepared(env *C.JNIEnv, class C.jclass) { - notifyVPNPrepared() -} - -//export Java_com_tailscale_ipn_IPNService_requestVPN -func Java_com_tailscale_ipn_IPNService_requestVPN(env *C.JNIEnv, this C.jobject) { - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - onVPNRequested <- jnipkg.NewGlobalRef(jenv, jnipkg.Object(this)) -} - -//export Java_com_tailscale_ipn_IPNService_connect -func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) { - onConnect <- ConnectEvent{Enable: true} -} - -//export Java_com_tailscale_ipn_IPNService_disconnect -func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) { - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - onDisconnect <- jnipkg.NewGlobalRef(jenv, jnipkg.Object(this)) -} - -//export Java_com_tailscale_ipn_StartVPNWorker_connect -func Java_com_tailscale_ipn_StartVPNWorker_connect(env *C.JNIEnv, this C.jobject) { - onConnect <- ConnectEvent{Enable: true} -} - -//export Java_com_tailscale_ipn_StopVPNWorker_disconnect -func Java_com_tailscale_ipn_StopVPNWorker_disconnect(env *C.JNIEnv, this C.jobject) { - onConnect <- ConnectEvent{Enable: false} -} - -//export Java_com_tailscale_ipn_Peer_onActivityResult0 -func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C.jclass, act C.jobject, reqCode, resCode C.jint) { - switch reqCode { - case requestSignin: - if resCode != resultOK { - onGoogleToken <- "" - break - } - jenv := (*jnipkg.Env)(unsafe.Pointer(env)) - m := jnipkg.GetStaticMethodID(jenv, googleClass, - "getIdTokenForActivity", "(Landroid/app/Activity;)Ljava/lang/String;") - idToken, err := jnipkg.CallStaticObjectMethod(jenv, googleClass, m, jnipkg.Value(act)) - if err != nil { - fatalErr(err) - break - } - tok := jnipkg.GoString(jenv, jnipkg.String(idToken)) - onGoogleToken <- tok - case requestPrepareVPN: - if resCode == resultOK { - notifyVPNPrepared() - } else { - notifyVPNClosed() - notifyVPNRevoked() - } - } -} - -//export Java_com_tailscale_ipn_App_onDnsConfigChanged -func Java_com_tailscale_ipn_App_onDnsConfigChanged(env *C.JNIEnv, cls C.jclass) { - select { - case onDNSConfigChanged <- struct{}{}: - default: - } -} - -func notifyVPNPrepared() { - select { - case onVPNPrepared <- struct{}{}: - default: - } -} - -func notifyVPNRevoked() { - select { - case onVPNRevoked <- struct{}{}: - default: - } -} - -func notifyVPNClosed() { - select { - case onVPNClosed <- struct{}{}: - default: - } -} - -var android struct { - // mu protects all fields of this structure. However, once a - // non-nil jvm is returned from javaVM, all the other fields may - // be accessed unlocked. - mu sync.Mutex - jvm *jnipkg.JVM - - // appCtx is the global Android App context. - appCtx C.jobject -}