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
parent
37832a5b72
commit
bb7ea7cf9f
@ -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)
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue