Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Integration Test

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

on:
workflow_dispatch:
pull_request:
types: [labeled, synchronize, reopened]

jobs:
integration-test:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-integration-test'))
runs-on: ubuntu-latest
timeout-minutes: 90

steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls -l /dev/kvm

- name: Run integration test
run: make android-integration-test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ tailscale.jks
# Persistent $HOME/.android for `make docker-*` (keeps debug.keystore so
# the debug signer is stable across container runs).
.android-docker
.android-integration-docker
.cache-docker
.gradle-docker

# Java profiling output
*.hprof
Expand Down
41 changes: 39 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
#
# The convention here is tailscale-android-build-amd64-<date>
DOCKER_IMAGE := tailscale-android-build-amd64-041425-1

# The integration test image contains the Android emulator, system image, SDK,
# build-tools, NDK, adb, and helper tools needed to run the emulator-backed Go
# integration tests. Bump this tag when docker/Dockerfile.android-integration
# or the required tool versions change, using:
# tailscale-android-integration-amd64-YYYYMMDD-N
ANDROID_INTEGRATION_DOCKER_IMAGE := tailscale-android-integration-amd64-20260526-3
export TS_USE_TOOLCHAIN=1

# If set, additional comma-separated build tags passed to the libtailscale Go
Expand Down Expand Up @@ -365,6 +372,14 @@ docker-build-image: ## Builds the docker image for the android build environment
docker build -f docker/DockerFile.amd64-build -t $(DOCKER_IMAGE) .; \
fi

.PHONY: docker-build-android-integration-image
docker-build-android-integration-image: ## Build the Docker image used to run Android integration tests
@echo "Checking if docker image $(ANDROID_INTEGRATION_DOCKER_IMAGE) already exists..."
@if ! docker images $(ANDROID_INTEGRATION_DOCKER_IMAGE) -q | grep -q . ; then \
echo "Image does not exist. Building..."; \
docker build -f docker/Dockerfile.android-integration -t $(ANDROID_INTEGRATION_DOCKER_IMAGE) .; \
fi

# DOCKER_ANDROID_DIR is bind-mounted as /root/.android inside the container
# so the Gradle-generated debug keystore (and anything else under ~/.android)
# persists across docker runs. Without this, every docker-based debug build
Expand All @@ -373,12 +388,19 @@ docker-build-image: ## Builds the docker image for the android build environment
# JVM's user.home resolves to /root for the container's root user, regardless
# of the Dockerfile's HOME=/build env.
DOCKER_ANDROID_DIR := $(CURDIR)/.android-docker
DOCKER_ANDROID_INTEGRATION_DIR := $(CURDIR)/.android-integration-docker
DOCKER_GRADLE_DIR := $(CURDIR)/.gradle-docker
DOCKER_GO_CACHE_DIR := $(CURDIR)/.cache-docker

.PHONY: docker-android-dir
docker-android-dir:
@mkdir -p $(DOCKER_ANDROID_DIR)
@mkdir -p $(DOCKER_ANDROID_DIR) $(DOCKER_GRADLE_DIR) $(DOCKER_GO_CACHE_DIR)

.PHONY: docker-android-integration-dir
docker-android-integration-dir:
@mkdir -p $(DOCKER_ANDROID_INTEGRATION_DIR) $(DOCKER_GO_CACHE_DIR)

DOCKER_RUN_VOLS := -v $(CURDIR):/build/tailscale-android -v $(DOCKER_ANDROID_DIR):/root/.android
DOCKER_RUN_VOLS := -v $(CURDIR):/build/tailscale-android -v $(DOCKER_ANDROID_DIR):/root/.android -v $(DOCKER_GRADLE_DIR):/build/.gradle -v $(DOCKER_GO_CACHE_DIR):/build/.cache --env GOPATH=/build/.cache/go --env GOMODCACHE=/build/.cache/go/pkg/mod

.PHONY: docker-run-build
docker-run-build: clean jarsign-env docker-build-image docker-android-dir ## Runs the docker image for the android build environment and builds release
Expand All @@ -388,6 +410,21 @@ docker-run-build: clean jarsign-env docker-build-image docker-android-dir ## Run
docker-tailscale-debug: docker-build-image docker-android-dir ## Build tailscale-debug.apk inside the docker env (stable signer across runs)
@docker run --rm $(DOCKER_RUN_VOLS) $(DOCKER_IMAGE) make tailscale-debug

.PHONY: android-integration-test
android-integration-test: docker-tailscale-debug android-integration-test-run ## Build APK and run adb-backed Android integration tests in Docker

.PHONY: android-integration-test-run
android-integration-test-run: docker-build-android-integration-image docker-android-integration-dir ## Run adb-backed Android integration tests in Docker using existing APK
@docker run --rm --device /dev/kvm \
-v $(CURDIR):/workspace \
-v $(DOCKER_ANDROID_INTEGRATION_DIR):/root/.android \
-v $(DOCKER_GO_CACHE_DIR):/root/.cache \
--env GOPATH=/root/.cache/go \
--env GOMODCACHE=/root/.cache/go/pkg/mod \
-w /workspace \
$(ANDROID_INTEGRATION_DOCKER_IMAGE) \
/usr/local/bin/run-android-integration-test /workspace/$(DEBUG_APK)

.PHONY: docker-remove-build-image
docker-remove-build-image: ## Removes the current docker build image
docker rmi --force $(DOCKER_IMAGE)
Expand Down
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ dependencies {
implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.1"
implementation "androidx.work:work-runtime-ktx:2.9.1"

// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
Expand Down
22 changes: 22 additions & 0 deletions android/src/main/java/com/tailscale/ipn/IPNReceiver.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class IPNReceiver extends BroadcastReceiver {

public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
public static final String INTENT_INTEGRATION_LOGIN = "com.tailscale.ipn.integration.LOGIN";
private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";

// Unique work names prevent connect/disconnect flapping from enqueuing a long backlog.
Expand Down Expand Up @@ -72,6 +73,27 @@ public void onReceive(Context context, Intent intent) {
.build();

workManager.enqueueUniqueWork(WORK_USE_EXIT_NODE, ExistingWorkPolicy.REPLACE, req);
} else if (BuildConfig.DEBUG && Objects.equals(action, INTENT_INTEGRATION_LOGIN)) {
Data input =
new Data.Builder()
.putString(
IntegrationLoginWorker.EXTRA_CONTROL_URL,
intent.getStringExtra(
IntegrationLoginWorker.EXTRA_CONTROL_URL))
.putString(
IntegrationLoginWorker.EXTRA_AUTH_KEY,
intent.getStringExtra(IntegrationLoginWorker.EXTRA_AUTH_KEY))
.build();

OneTimeWorkRequest req =
new OneTimeWorkRequest.Builder(IntegrationLoginWorker.class)
.setInputData(input)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(IntegrationLoginWorker.WORK_NAME)
.build();

workManager.enqueueUniqueWork(
IntegrationLoginWorker.WORK_NAME, ExistingWorkPolicy.REPLACE, req);
}
}
}
76 changes: 76 additions & 0 deletions android/src/main/java/com/tailscale/ipn/IntegrationLoginWorker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CompletableDeferred

/**
* Test-only worker used by emulator integration tests to log a debug APK into a local testcontrol
* server with an auth key.
*
* IPNReceiver only enqueues this worker when BuildConfig.DEBUG is true. Release builds do not
* expose the broadcast entry point, and minified final APKs strip this unreachable debug-only path.
*/
class IntegrationLoginWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
if (!BuildConfig.DEBUG) {
return Result.failure()
}

val controlURL = inputData.getString(EXTRA_CONTROL_URL)
val authKey = inputData.getString(EXTRA_AUTH_KEY)
if (controlURL.isNullOrBlank() || authKey.isNullOrBlank()) {
TSLog.e(TAG, "missing $EXTRA_CONTROL_URL or $EXTRA_AUTH_KEY")
return Result.failure()
}

return try {
login(controlURL, authKey)
Result.success()
} catch (e: Exception) {
TSLog.e(TAG, "integration login failed: $e")
Result.failure()
}
}

private suspend fun login(controlURL: String, authKey: String) {
val app = App.get()
app.startForegroundForLogin()

val client = Client(app.applicationScope)
val maskedPrefs =
Ipn.MaskedPrefs().apply {
ControlURL = controlURL
LoggedOut = false
}

val prefs = await<Ipn.Prefs> { client.editPrefs(maskedPrefs, it) }.getOrThrow()
prefs.WantRunning = true

val opts = Ipn.Options(UpdatePrefs = prefs, AuthKey = authKey)
await<Unit> { client.start(opts, it) }.getOrThrow()
await<Unit> { client.startLoginInteractive(it) }.getOrThrow()
app.startVPN()
}

private suspend fun <T> await(call: ((kotlin.Result<T>) -> Unit) -> Unit): kotlin.Result<T> {
val result = CompletableDeferred<kotlin.Result<T>>()
call { result.complete(it) }
return result.await()
}

companion object {
const val TAG = "IntegrationLoginWorker"
const val WORK_NAME = "ipn-integration-login"
const val EXTRA_CONTROL_URL = "control_url"
const val EXTRA_AUTH_KEY = "auth_key"
}
}
60 changes: 60 additions & 0 deletions docker/Dockerfile.android-integration
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause

FROM --platform=linux/amd64 eclipse-temurin:21

ENV ANDROID_HOME=/opt/android-sdk
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_CMDLINE_TOOLS_VERSION=9477386
ENV PATH=$PATH:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/emulator

RUN apt-get update && \
apt-get -y install --no-install-recommends \
ca-certificates \
curl \
gcc \
git \
libc6-dev \
libdbus-1-3 \
libfontconfig1 \
libgl1 \
libglib2.0-0 \
libnss3 \
libpulse0 \
libstdc++6 \
libvulkan1 \
libx11-6 \
libxcb-cursor0 \
libxcb1 \
libxext6 \
libxi6 \
libxrender1 \
libxtst6 \
make \
tar \
unzip \
zip && \
apt-get -y clean && \
rm -rf /var/lib/apt/lists/*

RUN mkdir -p /opt/android-sdk/cmdline-tools /tmp/android-sdk && \
curl -fsSL -o /tmp/android-sdk/commandlinetools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" && \
unzip -q /tmp/android-sdk/commandlinetools.zip -d /tmp/android-sdk && \
mv /tmp/android-sdk/cmdline-tools /opt/android-sdk/cmdline-tools/latest && \
rm -rf /tmp/android-sdk

RUN yes | sdkmanager --licenses >/dev/null && \
sdkmanager \
"build-tools;34.0.0" \
"emulator" \
"ndk;27.2.12479018" \
"platform-tools" \
"platforms;android-34" \
"system-images;android-33;google_apis;x86_64"

COPY scripts/run-android-integration-test-docker.sh /usr/local/bin/run-android-integration-test
RUN chmod 0755 /usr/local/bin/run-android-integration-test

WORKDIR /workspace
CMD ["/usr/local/bin/run-android-integration-test"]
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,19 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc // indirect
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
Expand Down Expand Up @@ -76,6 +79,7 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9po
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
Expand Down Expand Up @@ -137,6 +138,8 @@ github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
Expand All @@ -148,6 +151,10 @@ github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Q
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
Expand Down Expand Up @@ -210,6 +217,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
Expand All @@ -233,6 +242,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
Expand Down
Loading
Loading