diff --git a/android/build.gradle b/android/build.gradle index 62115c6..d028b2f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,28 +1,33 @@ + buildscript { + ext.kotlin_version = "1.9.22" + repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.0' + classpath "com.android.tools.build:gradle:8.1.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" } } -allprojects { - repositories { - google() - mavenCentral() - flatDir { - dirs 'libs' - } +repositories { + google() + mavenCentral() + flatDir { + dirs 'libs' } } +apply plugin: 'kotlin-android' apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.plugin.serialization' android { ndkVersion "23.1.7779620" - compileSdk 33 + compileSdkVersion 33 defaultConfig { minSdkVersion 22 targetSdkVersion 33 @@ -30,8 +35,8 @@ android { versionName "1.59.53-t0f042b981-g1017015de26" } compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } flavorDimensions "version" productFlavors { @@ -48,12 +53,18 @@ android { dependencies { implementation "androidx.core:core:1.9.0" + implementation 'androidx.core:core-ktx:1.9.0' implementation "androidx.browser:browser:1.5.0" implementation "androidx.security:security-crypto:1.1.0-alpha06" implementation "androidx.work:work-runtime:2.8.1" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation ':ipn@aar' testImplementation "junit:junit:4.12" // Non-free dependencies. playImplementation 'com.google.android.gms:play-services-auth:20.7.0' } + + diff --git a/android/src/main/java/com/tailscale/ipn/App.java b/android/src/main/java/com/tailscale/ipn/App.java index 644e145..0c6e9e1 100644 --- a/android/src/main/java/com/tailscale/ipn/App.java +++ b/android/src/main/java/com/tailscale/ipn/App.java @@ -70,6 +70,8 @@ import androidx.browser.customtabs.CustomTabsIntent; import org.gioui.Gio; +import com.tailscale.ipn.ui.localapi.LocalApiClient; + public class App extends Application { private static final String PEER_TAG = "peer"; @@ -88,6 +90,8 @@ public class App extends Application { public DnsConfig dns = new DnsConfig(); public DnsConfig getDnsConfigObj() { return this.dns; } + static final LocalApiClient api = new LocalApiClient(); + @Override public void onCreate() { super.onCreate(); // Load and initialize the Go library. @@ -98,8 +102,7 @@ public class App extends Application { createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT); createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW); - createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT); - + createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT); } // requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt new file mode 100644 index 0000000..9741679 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt @@ -0,0 +1,150 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.localapi + +import android.util.Log +import com.tailscale.ipn.ui.model.* +import kotlinx.coroutines.* + +// A response from the echo endpoint. +typealias StatusResponseHandler = (Result) -> Unit + +typealias BugReportIdHandler = (Result) -> Unit + +typealias PrefsHandler = (Result) -> Unit + +class LocalApiClient { + constructor() { + Log.d("LocalApiClient", "LocalApiClient created") + } + + // 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 with the response + // from the backend with a matching cookie. + // @see cmd/localapiclient/localapishim.go + // + // request: The path to the localAPI endpoint. + // method: The HTTP method to use. + // body: The body of the request. + // cookie: A unique identifier for this request. This is used map responses to + // the corresponding request. Cookies must be unique for each request. + external fun doRequest(request: String, method: String, body: String, cookie: String) + + fun executeRequest(request: LocalAPIRequest) { + Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}") + addRequest(request) + // The jni handler will treat the empty string in the body as null. + val body = request.body ?: "" + doRequest(request.path, request.method, body, request.cookie) + } + + // This is called from the JNI layer to publish localAPIResponses. This should execute on the + // same thread that called doRequest. + fun onResponse(response: String, cookie: String) { + val request = requests[cookie] + if (request != null) { + Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}") + // The response handler will invoked internally by the request parser + request.parser(response) + removeRequest(cookie) + } else { + Log.e("LocalApiClient", "Received response for unknown request: ${cookie}") + } + } + + // Tracks in-flight requests and their callback handlers by cookie. This should + // always be manipulated via the addRequest and removeRequest methods. + private var requests = HashMap>() + private var requestLock = Any() + + fun addRequest(request: LocalAPIRequest<*>) { + synchronized(requestLock) { requests[request.cookie] = request } + } + + fun removeRequest(cookie: String) { + synchronized(requestLock) { requests.remove(cookie) } + } + + // localapi Invocations + + fun getStatus(responseHandler: StatusResponseHandler) { + val req = LocalAPIRequest.status(responseHandler) + executeRequest(req) + } + + fun getBugReportId(responseHandler: BugReportIdHandler) { + val req = LocalAPIRequest.bugReportId(responseHandler) + executeRequest(req) + } + + fun getPrefs(responseHandler: PrefsHandler) { + val req = LocalAPIRequest.prefs(responseHandler) + executeRequest(req) + } + + // (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for + // a fully functioning client. This is a work in progress and will be updated + // See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters, + // and body contents for each endpoint. Endpoints are defined in LocalAPIEndpoint + // + // fetchFileTargets + // sendFiles + // getWaitingFiles + // recieveWaitingFile + // inidicateFileRecieved + // debug + // debugLog + // uploadClientMetrics + // start + // startLoginInteractive + // logout + // profiles + // currentProfile + // addProfile + // switchProfile + // deleteProfile + // tailnetLocalStatus + // signNode + // verifyDeepling + // ping + // setTailFSFileServerAddress + + // Run some tests to validate the APIs work before we have anything + // that calls them. This runs after a short delay to avoid not-ready + // errors + // (jonathan) TODO: Do we need some kind of "onReady" callback? + // (jonathan) TODO: Remove these we're further along + + fun runAPITests() = runBlocking { + delay(5000L) + getStatus { result -> + if (result.failed) { + Log.e("LocalApiClient", "Error getting status: ${result.error}") + } else { + val status = result.success + Log.d("LocalApiClient", "Got status: ${status}") + } + } + + getBugReportId { result -> + if (result.failed) { + Log.e("LocalApiClient", "Error getting bug report id: ${result.error}") + } else { + val bugReportId = result.success + Log.d("LocalApiClient", "Got bug report id: ${bugReportId}") + } + } + + getPrefs { result -> + if (result.failed) { + Log.e("LocalApiClient", "Error getting prefs: ${result.error}") + } else { + val prefs = result.success + Log.d("LocalApiClient", "Got prefs: ${prefs}") + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt new file mode 100644 index 0000000..c9380d2 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt @@ -0,0 +1,129 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.localapi + +import com.tailscale.ipn.ui.model.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +enum class LocalAPIEndpoint(val rawValue: String) { + Debug("debug"), + Debug_Log("debug-log"), + BugReport("bugreport"), + Prefs("prefs"), + FileTargets("file-targets"), + UploadMetrics("upload-client-metrics"), + Start("start"), + LoginInteractive("login-interactive"), + ResetAuth("reset-auth"), + Logout("logout"), + Profiles("profiles"), + ProfilesCurrent("profiles/current"), + Status("status"), + TKAStatus("tka/status"), + TKASitng("tka/sign"), + TKAVerifyDeeplink("tka/verify-deeplink"), + Ping("ping"), + Files("files"), + FilePut("file-put"), + TailFSServerAddress("tailfs/fileserver-address"); + + val prefix = "/localapi/v0/" + + fun path(): String { + return prefix + rawValue + } +} + +// Potential local and upstream errors. Error handling in localapi in the go layer +// is inconsistent but different clients already deal with that inconsistency so +// 'fixing' it will likely break other things. +// +// For now, anything that isn't an { error: "message" } will be passed along +// as UNPARSEABLE_RESPONSE. We can add additional error translation in the parseError +// method as needed. +// +// (jonathan) TODO: Audit local API for all of the possible error results and clean +// it up if possible. +enum class APIErrorVals(val rawValue: String) { + UNPARSEABLE_RESPONSE("Unparseable localAPI response"), + NOT_READY("Not Ready"); + + fun toError(): Error { + return Error(rawValue) + } +} + +class LocalAPIRequest( + val path: String, + val method: String, + val body: String? = null, + val responseHandler: (Result) -> Unit, + val parser: (String) -> Unit, +) { + companion object { + val cookieLock = Any() + var cookieCounter: Int = 0 + val decoder = Json { ignoreUnknownKeys = true } + + fun getCookie(): String { + synchronized(cookieLock) { + cookieCounter += 1 + return cookieCounter.toString() + } + } + + fun status(responseHandler: StatusResponseHandler): LocalAPIRequest { + val path = LocalAPIEndpoint.Status.path() + return LocalAPIRequest(path, "GET", null, responseHandler) { resp -> + responseHandler(decode(resp)) + } + } + + fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest { + val path = LocalAPIEndpoint.BugReport.path() + return LocalAPIRequest(path, "POST", null, responseHandler) { resp -> + responseHandler(parseString(resp)) + } + } + + fun prefs(responseHandler: PrefsHandler): LocalAPIRequest { + val path = LocalAPIEndpoint.Prefs.path() + return LocalAPIRequest(path, "GET", null, responseHandler) { resp -> + responseHandler(decode(resp)) + } + } + + // Check if the response was a generic error + fun parseError(respData: String): Error { + try { + val err = Json.decodeFromString(respData) + return Error(err.error) + } catch (e: Exception) { + return Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError()) + } + } + + // Handles responses that are raw strings. Returns an error result if the string + // is empty + fun parseString(respData: String): Result { + return if (respData.length > 0) Result(respData) + else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError()) + } + + // Attempt to decode the response into the expected type. If that fails, then try + // parsing as an error. + inline fun decode(respData: String): Result { + try { + val message = decoder.decodeFromString(respData) + return Result(message) + } catch (e: Exception) { + return Result(parseError(respData)) + } + } + } + + val cookie: String = getCookie() +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt new file mode 100644 index 0000000..7d9ec5a --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt @@ -0,0 +1,33 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.localapi + +// Go-like result type with an optional value and an optional Error +// This guarantees that only one of the two is non-null +class Result { + val success: T? + val error: Error? + + constructor(success: T?, error: Error?) { + if (success != null && error != null) { + throw IllegalArgumentException("Result cannot have both a success and an error") + } + if (success == null && error == null) { + throw IllegalArgumentException("Result must have either a success or an error") + } + + this.success = success + this.error = error + } + + constructor(success: T) : this(success, null) {} + constructor(error: Error) : this(null, error) {} + + var successful: Boolean = false + get() = success != null + + var failed: Boolean = false + get() = error != null +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt new file mode 100644 index 0000000..3a702e9 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt @@ -0,0 +1,30 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.model + +import kotlinx.serialization.* + +class Dns { + @Serializable data class HostEntry(val addr: Addr?, val hosts: List?) + + @Serializable + data class OSConfig( + val hosts: List? = null, + val nameservers: List? = null, + val searchDomains: List? = null, + val matchDomains: List? = null, + ) { + val isEmpty: Boolean + get() = (hosts.isNullOrEmpty()) && + (nameservers.isNullOrEmpty()) && + (searchDomains.isNullOrEmpty()) && + (matchDomains.isNullOrEmpty()) + } +} + +class DnsType { + @Serializable + data class Resolver(var Addr: String? = null, var BootstrapResolution: List? = null) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt new file mode 100644 index 0000000..4b83953 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -0,0 +1,168 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.model + +import kotlinx.serialization.* + +class Ipn { + + // Represents the overall state of the Tailscale engine. + enum class State(val value: Int) { + NoState(0), + InUseOtherUser(1), + NeedsLogin(2), + NeedsMachineAuth(3), + Stopped(4), + Starting(5), + Running(6), + } + + // NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which + // what we want to see on the Noitfy bus + enum class NotifyWatchOpt(val value: Int) { + engineUpdates(0), + initialState(1 shl 1), + prefs(1 shl 2), + netmap(1 shl 3), + noPrivateKeys(1 shl 4), + initialTailFSShares(1 shl 5) + } + + // A nofitication message recieved on the Notify bus. Fields will be populated based + // on which NotifyWatchOpts were set when the Notifier was created. + @Serializable + data class Notify( + val Version: String? = null, + val ErrMessage: String? = null, + val LoginFinished: Empty.Message? = null, + val FilesWaiting: Empty.Message? = null, + val State: State? = null, + var Prefs: Prefs? = null, + var NetMap: Netmap.NetworkMap? = null, + var Engine: EngineStatus? = null, + var BrowseToURL: String? = null, + var BackendLogId: String? = null, + var LocalTCPPort: Int? = null, + var IncomingFiles: List? = null, + var ClientVersion: Tailcfg.ClientVersion? = null, + var TailFSShares: Map? = null, + ) + + @Serializable + data class Prefs( + var ControlURL: String = "", + var RouteAll: Boolean = false, + var AllowsSingleHosts: Boolean = false, + var CorpDNS: Boolean = false, + var WantRunning: Boolean = false, + var LoggedOut: Boolean = false, + var ShieldsUp: Boolean = false, + var AdvertiseRoutes: List? = null, + var AdvertiseTags: List? = null, + var ExitNodeId: StableNodeID? = null, + var ExitNodeAllowLanAccess: Boolean = false, + var Config: Persist.Persist? = null, + var ForceDaemon: Boolean = false, + var HostName: String = "", + var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true), + ) + + @Serializable + data class MaskedPrefs( + var RouteAllSet: Boolean? = null, + var CorpDNSSet: Boolean? = null, + var ExitNodeIDSet: Boolean? = null, + var ExitNodeAllowLANAccessSet: Boolean? = null, + var WantRunningSet: Boolean? = null, + var ShieldsUpSet: Boolean? = null, + var AdvertiseRoutesSet: Boolean? = null, + var ForceDaemonSet: Boolean? = null, + var HostnameSet: Boolean? = null, + ) { + var RouteAll: Boolean? = null + set(value) { + field = value + RouteAllSet = true + } + var CorpDNS: Boolean? = null + set(value) { + field = value + CorpDNSSet = true + } + var ExitNodeId: StableNodeID? = null + set(value) { + field = value + ExitNodeIDSet = true + } + var ExitNodeAllowLanAccess: Boolean? = null + set(value) { + field = value + ExitNodeAllowLANAccessSet = true + } + var WantRunning: Boolean? = null + set(value) { + field = value + WantRunningSet = true + } + var ShieldsUp: Boolean? = null + set(value) { + field = value + ShieldsUpSet = true + } + var AdvertiseRoutes: Boolean? = null + set(value) { + field = value + AdvertiseRoutesSet = true + } + var ForceDaemon: Boolean? = null + set(value) { + field = value + ForceDaemonSet = true + } + var Hostname: Boolean? = null + set(value) { + field = value + HostnameSet = true + } + } + + @Serializable + data class AutoUpdatePrefs( + var Check: Boolean? = null, + var Apply: Boolean? = null, + ) + + @Serializable + data class EngineStatus( + val RBytes: Long, + val WBytes: Long, + val NumLive: Int, + val LivePeers: Map, + ) + + @Serializable + data class PartialFile( + val Name: String, + val Started: String, + val DeclaredSize: Long, + val Received: Long, + val PartialPath: String? = null, + var FinalPath: String? = null, + val Done: Boolean? = null, + ) +} + +class Persist { + @Serializable + data class Persist( + var PrivateMachineKey: String = + "privkey:0000000000000000000000000000000000000000000000000000000000000000", + var PrivateNodeKey: String = + "privkey:0000000000000000000000000000000000000000000000000000000000000000", + var OldPrivateNodeKey: String = + "privkey:0000000000000000000000000000000000000000000000000000000000000000", + var Provider: String = "", + ) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt new file mode 100644 index 0000000..779dbca --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -0,0 +1,117 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.model + +import kotlinx.serialization.* + +class IpnState { + @Serializable + data class PeerStatusLite( + val RxBytes: Long, + val TxBytes: Long, + val LastHandshake: String, + val NodeKey: String, + ) + + @Serializable + data class PeerStatus( + val ID: StableNodeID, + val HostName: String, + val DNSName: String, + val TailscaleIPs: List? = null, + val Tags: List? = null, + val PrimaryRoutes: List? = null, + val Addrs: List? = null, + val Online: Boolean, + val ExitNode: Boolean, + val ExitNodeOption: Boolean, + val PeerAPIURL: List? = null, + val Capabilities: List? = null, + val SSH_HostKeys: List? = null, + val ShareeNode: Boolean? = null, + val Expired: Boolean? = null, + val Location: Tailcfg.Location? = null, + ) { + fun computedName(status: Status): String { + val name = DNSName + val suffix = status.CurrentTailnet?.MagicDNSSuffix + + suffix ?: return name + + if (!(name.endsWith("." + suffix + "."))) { + return name + } + + return name.dropLast(suffix.count() + 2) + } + } + + @Serializable + data class ExitNodeStatus( + val ID: StableNodeID, + val Online: Boolean, + val TailscaleIPs: List? = null, + ) + + @Serializable + data class TailnetStatus( + val Name: String, + val MagicDNSSuffix: String, + val MagicDNSEnabled: Boolean, + ) + + @Serializable + data class Status( + val Version: String, + val TUN: Boolean, + val BackendState: String, + val AuthURL: String, + val TailscaleIPs: List? = null, + val Self: PeerStatus? = null, + val ExitNodeStatus: ExitNodeStatus? = null, + val Health: List? = null, + val CurrentTailnet: TailnetStatus? = null, + val CertDomains: List? = null, + val Peer: Map? = null, + val User: Map? = null, + val ClientVersion: Tailcfg.ClientVersion? = null, + ) + + @Serializable + data class NetworkLockStatus( + var Enabled: Boolean, + var PublicKey: String, + var NodeKey: String, + var NodeKeySigned: Boolean, + var FilteredPeers: List? = null, + var StateID: ULong? = null, + ) + + @Serializable + data class TKAFilteredPeer( + var Name: String, + var TailscaleIPs: List, + var NodeKey: String, + ) + + @Serializable + data class PingResult( + var IP: Addr, + var Err: String, + var LatencySeconds: Double, + ) +} + +class IpnLocal { + @Serializable + data class LoginProfile( + var ID: String, + val Name: String, + val Key: String, + val UserProfile: Tailcfg.UserProfile, + val NetworkProfile: Tailcfg.NetworkProfile? = null, + val LocalUserID: String, + ) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt new file mode 100644 index 0000000..2ca1511 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -0,0 +1,49 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.model + +import kotlinx.serialization.* + +class Netmap { + @Serializable + data class NetworkMap( + var SelfNode: Tailcfg.Node, + var NodeKey: KeyNodePublic, + var Peers: List? = null, + var Expiry: Time, + var Domain: String, + var UserProfiles: Map, + var TKAEnabled: Boolean, + var DNS: Tailcfg.DNSConfig? = null + ) { + // Keys are tailcfg.UserIDs thet get stringified + // Helpers + fun currentUserProfile(): Tailcfg.UserProfile? { + return userProfile(User()) + } + + fun User(): UserID { + return SelfNode.User + } + + fun userProfile(id: Long): Tailcfg.UserProfile? { + return UserProfiles[id.toString()] + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is NetworkMap) return false + + return SelfNode == other.SelfNode && + NodeKey == other.NodeKey && + Peers == other.Peers && + Expiry == other.Expiry && + User() == other.User() && + Domain == other.Domain && + UserProfiles == other.UserProfiles && + TKAEnabled == other.TKAEnabled + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt new file mode 100644 index 0000000..6a774af --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -0,0 +1,100 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package com.tailscale.ipn.ui.model + +import kotlinx.serialization.* + +class Tailcfg { + @Serializable + data class ClientVersion( + var RunningLatest: Boolean? = null, + var LatestVersion: String? = null, + var UrgentSecurityUpdate: Boolean? = null, + var Notify: Boolean? = null, + var NotifyURL: String? = null, + var NotifyText: String? = null + ) + + @Serializable + data class UserProfile( + val ID: Long, + val DisplayName: String, + val LoginName: String, + val ProfilePicURL: String? = null, + ) { + fun isTaggedDevice(): Boolean { + return LoginName == "tagged-devices" + } + } + + @Serializable + data class Hostinfo( + var IPNVersion: String? = null, + var FrontendLogID: String? = null, + var BackendLogID: String? = null, + var OS: String? = null, + var OSVersion: String? = null, + var Env: String? = null, + var Distro: String? = null, + var DistroVersion: String? = null, + var DistroCodeName: String? = null, + var Desktop: Boolean? = null, + var Package: String? = null, + var DeviceModel: String? = null, + var ShareeNode: Boolean? = null, + var Hostname: String? = null, + var ShieldsUp: Boolean? = null, + var NoLogsNoSupport: Boolean? = null, + var Machine: String? = null, + var RoutableIPs: List? = null, + var Services: List? = null, + ) + + @Serializable + data class Node( + var ID: NodeID, + var StableID: StableNodeID, + var Name: String, + var User: UserID, + var Sharer: UserID? = null, + var Key: KeyNodePublic, + var KeyExpiry: String, + var Machine: MachineKey, + var Addresses: List? = null, + var AllowedIPs: List? = null, + var Endpoints: List? = null, + var Hostinfo: Hostinfo, + var Created: Time, + var LastSeen: Time? = null, + var Online: Boolean? = null, + var Capabilities: List? = null, + var ComputedName: String, + var ComputedNameWithHost: String + ) + + @Serializable + data class Service(var Proto: String, var Port: Int, var Description: String? = null) + + @Serializable + data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null) + + @Serializable + data class Location( + var Country: String? = null, + var CountryCode: String? = null, + var City: String? = null, + var CityCode: String? = null, + var Priority: Int? = null + ) + + @Serializable + data class DNSConfig( + var Resolvers: List? = null, + var Routes: Map?>? = null, + var FallbackResolvers: List? = null, + var Domains: List? = null, + var Nameservers: List? = null + ) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt new file mode 100644 index 0000000..32e0415 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + + +package com.tailscale.ipn.ui.model +import kotlinx.serialization.* + +typealias Addr = String +typealias Prefix = String +typealias NodeID = Long +typealias KeyNodePublic = String +typealias MachineKey = String +typealias UserID = Long +typealias Time = String +typealias StableNodeID = String +typealias BugReportID = String + +// Represents and empty message with a single 'property' field. +class Empty { + @Serializable + data class Message(val property: String) +} + +// Parsable errors returned by localApiService +class Errors { + @Serializable + data class GenericError(val error: String) +} + +// Returned on successful operations with no explicit response body +class Success { + @Serializable + data class GenericSuccess(val message: String) +} \ No newline at end of file diff --git a/jni/jni.go b/cmd/jni/jni.go similarity index 100% rename from jni/jni.go rename to cmd/jni/jni.go diff --git a/cmd/localapiclient/localapiclient.go b/cmd/localapiclient/localapiclient.go deleted file mode 100644 index efc4222..0000000 --- a/cmd/localapiclient/localapiclient.go +++ /dev/null @@ -1,100 +0,0 @@ -package localapiclient - -import ( - "context" - "fmt" - "io" - "net" - "net/http" - "sync" -) - -// Response represents the result of processing an http.Request. -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 and will send the data to Java. -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) - }) -} - -type LocalAPIClient struct { - h http.Handler -} - -func New(h http.Handler) *LocalAPIClient { - return &LocalAPIClient{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 *LocalAPIClient) Call(ctx context.Context, method, endpoint string, body io.Reader) (*Response, error) { - req, err := http.NewRequestWithContext(ctx, method, "/localapi/v0/"+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) - } -} diff --git a/cmd/localapiclient/localapiclient_test.go b/cmd/localapiservice/localapi_test.go similarity index 98% rename from cmd/localapiclient/localapiclient_test.go rename to cmd/localapiservice/localapi_test.go index 6c4db54..0aef288 100644 --- a/cmd/localapiclient/localapiclient_test.go +++ b/cmd/localapiservice/localapi_test.go @@ -1,4 +1,4 @@ -package localapiclient +package localapiservice import ( "context" diff --git a/cmd/localapiservice/localapiservice.go b/cmd/localapiservice/localapiservice.go new file mode 100644 index 0000000..ad925d4 --- /dev/null +++ b/cmd/localapiservice/localapiservice.go @@ -0,0 +1,110 @@ +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/cmd/localapiservice/localapishim.go b/cmd/localapiservice/localapishim.go new file mode 100644 index 0000000..9b26aeb --- /dev/null +++ b/cmd/localapiservice/localapishim.go @@ -0,0 +1,106 @@ +// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package localapiservice + +import ( + "context" + "io" + "strings" + "time" + "unsafe" + + "github.com/tailscale/tailscale-android/cmd/jni" +) + +// #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 jni.Class + + // Typically a shared LocalAPIService instance. + service *LocalAPIService +} + +//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.jstring, + jcookie C.jstring) { + + jenv := (*jni.Env)(unsafe.Pointer(env)) + + // The API Path + pathRef := jni.NewGlobalRef(jenv, jni.Object(jpath)) + pathStr := jni.GoString(jenv, jni.String(pathRef)) + defer jni.DeleteGlobalRef(jenv, pathRef) + + // The HTTP verb + methodRef := jni.NewGlobalRef(jenv, jni.Object(jmethod)) + methodStr := jni.GoString(jenv, jni.String(methodRef)) + defer jni.DeleteGlobalRef(jenv, methodRef) + + // The body string. This is optional and may be empty. + bodyRef := jni.NewGlobalRef(jenv, jni.Object(jbody)) + bodyStr := jni.GoString(jenv, jni.String(bodyRef)) + defer jni.DeleteGlobalRef(jenv, bodyRef) + + resp := doLocalAPIRequest(pathStr, methodStr, bodyStr) + + jrespBody := jni.JavaString(jenv, resp) + respBody := jni.Value(jrespBody) + cookie := jni.Value(jcookie) + onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Ljava/lang/String;Ljava/lang/String;)V") + + jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie) +} + +func doLocalAPIRequest(path string, method string, body string) string { + if shim.service == nil { + return "{\"error\":\"Not Ready\"}" + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var reader io.Reader = nil + if len(body) > 0 { + reader = strings.NewReader(body) + } + + r, err := shim.service.Call(ctx, method, path, reader) + defer r.Body().Close() + + if err != nil { + return "{\"error\":\"" + err.Error() + "\"}" + } + respBytes, err := io.ReadAll(r.Body()) + if err != nil { + return "{\"error\":\"" + err.Error() + "\"}" + } + return string(respBytes) +} + +// Assign a localAPIService to our shim for handling incoming localapi requests from the Kotlin side. +func SetLocalAPIService(s *LocalAPIService) { + shim.service = s +} + +// Loads the Kotlin-side LocalApiClient class and stores it in a global reference. +func ConfigureLocalApiJNIHandler(jvm *jni.JVM, appCtx jni.Object) error { + return jni.Do(jvm, func(env *jni.Env) error { + loader := jni.ClassLoaderFor(env, appCtx) + cl, err := jni.LoadClass(env, loader, "com.tailscale.ipn.ui.localapi.LocalApiClient") + if err != nil { + return err + } + shim.clientClass = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) + return nil + }) +} diff --git a/cmd/localapiservice/response.go b/cmd/localapiservice/response.go new file mode 100644 index 0000000..477da84 --- /dev/null +++ b/cmd/localapiservice/response.go @@ -0,0 +1,50 @@ +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/cmd/tailscale/backend.go b/cmd/tailscale/backend.go index fe52631..9de17c3 100644 --- a/cmd/tailscale/backend.go +++ b/cmd/tailscale/backend.go @@ -15,8 +15,9 @@ import ( "strings" "time" - "github.com/tailscale/tailscale-android/jni" "github.com/tailscale/wireguard-go/tun" + "github.com/tailscale/tailscale-android/cmd/jni" + "golang.org/x/sys/unix" "tailscale.com/ipn" "tailscale.com/ipn/ipnlocal" diff --git a/cmd/tailscale/callbacks.go b/cmd/tailscale/callbacks.go index 5ed3889..d18f260 100644 --- a/cmd/tailscale/callbacks.go +++ b/cmd/tailscale/callbacks.go @@ -9,7 +9,7 @@ package main import ( "unsafe" - "github.com/tailscale/tailscale-android/jni" + "github.com/tailscale/tailscale-android/cmd/jni" ) // #include diff --git a/cmd/tailscale/main.go b/cmd/tailscale/main.go index b13e713..619e0bc 100644 --- a/cmd/tailscale/main.go +++ b/cmd/tailscale/main.go @@ -35,8 +35,9 @@ import ( "golang.org/x/exp/maps" "inet.af/netaddr" - "github.com/tailscale/tailscale-android/cmd/localapiclient" - "github.com/tailscale/tailscale-android/jni" + "github.com/tailscale/tailscale-android/cmd/jni" + "github.com/tailscale/tailscale-android/cmd/localapiservice" + "tailscale.com/client/tailscale/apitype" "tailscale.com/hostinfo" "tailscale.com/ipn" @@ -60,8 +61,8 @@ type App struct { store *stateStore logIDPublicAtomic atomic.Pointer[logid.PublicID] - localAPIClient *localapiclient.LocalAPIClient - backend *ipnlocal.LocalBackend + localAPI *localapiservice.LocalAPIService + backend *ipnlocal.LocalBackend // netStates receives the most recent network state. netStates chan BackendState @@ -232,19 +233,17 @@ func main() { invalidates: make(chan struct{}, 1), bugReport: make(chan string, 1), } - err := jni.Do(a.jvm, func(env *jni.Env) error { - loader := jni.ClassLoaderFor(env, a.appCtx) - cl, err := jni.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 = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) - return nil - }) + + err := a.loadJNIGlobalClassRefs() if err != nil { fatalErr(err) } + + err = localapiservice.ConfigureLocalApiJNIHandler(a.jvm, a.appCtx) + if err != nil { + fatalErr(err) + } + a.store = newStateStore(a.jvm, a.appCtx) interfaces.RegisterInterfaceGetter(a.getInterfaces) go func() { @@ -261,6 +260,21 @@ func main() { app.Main() } +// 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 jni.Do(a.jvm, func(env *jni.Env) error { + loader := jni.ClassLoaderFor(env, a.appCtx) + cl, err := jni.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 = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) + return nil + }) +} + func (a *App) runBackend(ctx context.Context) error { appDir, err := app.DataDir() if err != nil { @@ -300,7 +314,10 @@ 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.localAPIClient = localapiclient.New(h) + a.localAPI = localapiservice.New(h) + + // Share the localAPI with the JNI shim + localapiservice.SetLocalAPIService(a.localAPI) // Contrary to the documentation for VpnService.Builder.addDnsServer, // ChromeOS doesn't fall back to the underlying network nameservers if @@ -447,7 +464,7 @@ func (a *App) runBackend(ctx context.Context) error { case BugEvent: backendLogIDStr := a.logIDPublicAtomic.Load().String() fallbackLog := fmt.Sprintf("BUG-%v-%v-%v", backendLogIDStr, time.Now().UTC().Format("20060102150405Z"), randHex(8)) - a.getBugReportID(ctx, a.bugReport, fallbackLog) + a.localAPI.GetBugReportID(ctx, a.bugReport, fallbackLog) case OAuth2Event: go b.backend.Login(e.Token) case BeExitNodeEvent: @@ -458,7 +475,7 @@ func (a *App) runBackend(ctx context.Context) error { go b.backend.SetPrefs(state.Prefs) case WebAuthEvent: if !signingIn { - go a.login(ctx) + go a.localAPI.Login(ctx, a.backend) signingIn = true } case SetLoginServerEvent: @@ -474,7 +491,7 @@ func (a *App) runBackend(ctx context.Context) error { } }() case LogoutEvent: - go a.logout(ctx) + go a.localAPI.Logout(ctx, a.backend) case ConnectEvent: state.Prefs.WantRunning = e.Enable go b.backend.SetPrefs(state.Prefs) @@ -565,54 +582,6 @@ func (a *App) runBackend(ctx context.Context) error { } } -func (a *App) getBugReportID(ctx context.Context, bugReportChan chan<- string, fallbackLog string) { - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - r, err := a.localAPIClient.Call(ctx, "POST", "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 (a *App) login(ctx context.Context) { - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - r, err := a.localAPIClient.Call(ctx, "POST", "login-interactive", nil) - defer r.Body().Close() - - if err != nil { - log.Printf("login: %s", err) - a.backend.StartLoginInteractive() - } -} - -func (a *App) logout(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - r, err := a.localAPIClient.Call(ctx, "POST", "logout", nil) - defer r.Body().Close() - - if err != nil { - log.Printf("logout: %s", err) - logoutctx, logoutcancel := context.WithTimeout(ctx, 5*time.Minute) - defer logoutcancel() - a.backend.Logout(logoutctx) - } - - return err -} - func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error { files, err := b.WaitingFiles() if err != nil { diff --git a/cmd/tailscale/store.go b/cmd/tailscale/store.go index e72d012..0da0c5c 100644 --- a/cmd/tailscale/store.go +++ b/cmd/tailscale/store.go @@ -9,7 +9,7 @@ import ( "tailscale.com/ipn" - "github.com/tailscale/tailscale-android/jni" + "github.com/tailscale/tailscale-android/cmd/jni" ) // stateStore is the Go interface for a persistent storage