android: add kotlin dependencies build the kotlin->go localAPIClient (#173)

updates ENG-2805

Adds all of the kotlin build dependencies and a partial implementation of a LocalAPIClient in the front end, wired up via JNI.  The general idea here is to mimic the architecture used on other Tailscale clients, where the front ends largely interact with the backend via "localapi".

The LocalAPIClient in go has been renamed to LocalAPIService to avoid confusion with the implementation on the future client side in Kotlin.  Some mild refactoring was done to make the localAPI invocations methods on the api service instead of App.

Streaming notifier endpoints like watch-ipn-bus are not supported.  We will build out a separate set of JNI methods for dealing with those.

The jni package is moved under cmd where it is used.

This constains mostly-complete implementation of the required localAPI data classes based on the pieces that are used by the iOS and macOS clients.  The LocalAPIClient itself does not implement all of the endpoints, but is ready to do so when those APIs are needed by a UI component.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/167/head
Jonathan Nobels 9 months ago committed by GitHub
parent 37832a5b72
commit bb7ea7cf9f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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'
}

@ -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.
@ -99,7 +103,6 @@ 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);
}
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that

@ -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<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> 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 <T> executeRequest(request: LocalAPIRequest<T>) {
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<String, LocalAPIRequest<*>>()
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<IpnState.Status>(req)
}
fun getBugReportId(responseHandler: BugReportIdHandler) {
val req = LocalAPIRequest.bugReportId(responseHandler)
executeRequest<BugReportID>(req)
}
fun getPrefs(responseHandler: PrefsHandler) {
val req = LocalAPIRequest.prefs(responseHandler)
executeRequest<Ipn.Prefs>(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}")
}
}
}
}

@ -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<T>(
val path: String,
val method: String,
val body: String? = null,
val responseHandler: (Result<T>) -> 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<IpnState.Status> {
val path = LocalAPIEndpoint.Status.path()
return LocalAPIRequest<IpnState.Status>(path, "GET", null, responseHandler) { resp ->
responseHandler(decode<IpnState.Status>(resp))
}
}
fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest<BugReportID> {
val path = LocalAPIEndpoint.BugReport.path()
return LocalAPIRequest<BugReportID>(path, "POST", null, responseHandler) { resp ->
responseHandler(parseString(resp))
}
}
fun prefs(responseHandler: PrefsHandler): LocalAPIRequest<Ipn.Prefs> {
val path = LocalAPIEndpoint.Prefs.path()
return LocalAPIRequest<Ipn.Prefs>(path, "GET", null, responseHandler) { resp ->
responseHandler(decode<Ipn.Prefs>(resp))
}
}
// Check if the response was a generic error
fun parseError(respData: String): Error {
try {
val err = Json.decodeFromString<Errors.GenericError>(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<String> {
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 <reified T> decode(respData: String): Result<T> {
try {
val message = decoder.decodeFromString<T>(respData)
return Result(message)
} catch (e: Exception) {
return Result(parseError(respData))
}
}
}
val cookie: String = getCookie()
}

@ -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<T> {
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
}

@ -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<String>?)
@Serializable
data class OSConfig(
val hosts: List<HostEntry>? = null,
val nameservers: List<Addr>? = null,
val searchDomains: List<String>? = null,
val matchDomains: List<String>? = 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<Addr>? = null)
}

@ -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<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: Map<String, String>? = 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<String>? = null,
var AdvertiseTags: List<String>? = 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<String, IpnState.PeerStatusLite>,
)
@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 = "",
)
}

@ -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<Addr>? = null,
val Tags: List<String>? = null,
val PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null,
val Online: Boolean,
val ExitNode: Boolean,
val ExitNodeOption: Boolean,
val PeerAPIURL: List<String>? = null,
val Capabilities: List<String>? = null,
val SSH_HostKeys: List<String>? = 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<Prefix>? = 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<Addr>? = null,
val Self: PeerStatus? = null,
val ExitNodeStatus: ExitNodeStatus? = null,
val Health: List<String>? = null,
val CurrentTailnet: TailnetStatus? = null,
val CertDomains: List<String>? = null,
val Peer: Map<String, PeerStatus>? = null,
val User: Map<String, Tailcfg.UserProfile>? = 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<TKAFilteredPeer>? = null,
var StateID: ULong? = null,
)
@Serializable
data class TKAFilteredPeer(
var Name: String,
var TailscaleIPs: List<Addr>,
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,
)
}

@ -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<Tailcfg.Node>? = null,
var Expiry: Time,
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
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
}
}
}

@ -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<Prefix>? = null,
var Services: List<Service>? = 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<Prefix>? = null,
var AllowedIPs: List<Prefix>? = null,
var Endpoints: List<String>? = null,
var Hostinfo: Hostinfo,
var Created: Time,
var LastSeen: Time? = null,
var Online: Boolean? = null,
var Capabilities: List<String>? = 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<DnsType.Resolver>? = null,
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
var FallbackResolvers: List<DnsType.Resolver>? = null,
var Domains: List<String>? = null,
var Nameservers: List<Addr>? = null
)
}

@ -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)
}

@ -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)
}
}

@ -1,4 +1,4 @@
package localapiclient
package localapiservice
import (
"context"

@ -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
}

@ -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 <jni.h>
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
})
}

@ -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)
})
}

@ -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"

@ -9,7 +9,7 @@ package main
import (
"unsafe"
"github.com/tailscale/tailscale-android/jni"
"github.com/tailscale/tailscale-android/cmd/jni"
)
// #include <jni.h>

@ -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 {

@ -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

Loading…
Cancel
Save