├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── PATENTS ├── README.md ├── android ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── src │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── tailscale │ │ │ └── ipn │ │ │ ├── App.java │ │ │ ├── DnsConfig.java │ │ │ ├── IPNActivity.java │ │ │ ├── IPNService.java │ │ │ ├── Peer.java │ │ │ └── QuickToggleService.java │ └── res │ │ ├── drawable-hdpi │ │ └── ic_notification.png │ │ ├── drawable-mdpi │ │ └── ic_notification.png │ │ ├── drawable-xhdpi │ │ ├── ic_notification.png │ │ └── tv_banner.png │ │ ├── drawable-xxhdpi │ │ └── ic_notification.png │ │ ├── drawable-xxxhdpi │ │ └── ic_notification.png │ │ ├── drawable │ │ ├── ic_launcher_foreground.xml │ │ └── ic_tile.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── ic_launcher_background.xml │ │ └── strings.xml │ ├── play │ └── java │ │ └── com │ │ └── tailscale │ │ └── ipn │ │ └── Google.java │ └── test │ └── java │ └── com │ └── tailscale │ └── ipn │ └── DnsConfigTest.java ├── cmd └── tailscale │ ├── backend.go │ ├── callbacks.go │ ├── google.png │ ├── main.go │ ├── multitun.go │ ├── pprof.go │ ├── store.go │ ├── tailscale.png │ ├── tools.go │ └── ui.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── jni └── jni.go ├── metadata └── en-US │ ├── full_description.txt │ ├── images │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ └── short_description.txt └── version └── tailscale-version.sh /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v3 27 | 28 | # Runs a single command using the runners shell 29 | - name: Create Docker container 30 | run: docker build . --file Dockerfile --tag tailscale-android 31 | 32 | # Runs a set of commands using the runners shell 33 | - name: build tailscale-fdroid.apk 34 | run: docker run -v /home/runner/work/tailscale-android/tailscale-android:/build/tailscale-android tailscale-android make tailscale-fdroid.apk 35 | 36 | - name: Upload a Build Artifact 37 | uses: actions/upload-artifact@v3.0.0 38 | with: 39 | # Artifact name 40 | name: tailscale-fdroid.apk # optional, default is artifact 41 | # A file, directory or wildcard pattern that describes what to upload 42 | path: /home/runner/work/tailscale-android/tailscale-android/tailscale-fdroid.apk 43 | # The desired behavior if no files are found using the provided path. 44 | if-no-files-found: error # optional, default is warn 45 | 46 | 47 | retention-days: 30 # optional 48 | 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Gradle project-specific cache directory 2 | .gradle 3 | 4 | # Ignore Gradle build output directory 5 | build 6 | 7 | # The destination for the Go Android archive. 8 | android/libs 9 | 10 | # Output files from the Makefile: 11 | tailscale-debug.apk 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This is a Dockerfile for creating a build environment for 2 | # tailscale-android. 3 | 4 | FROM openjdk:8-jdk 5 | 6 | # To enable running android tools such as aapt 7 | RUN apt-get update && apt-get -y upgrade 8 | RUN apt-get install -y lib32z1 lib32stdc++6 9 | # For Go: 10 | RUN apt-get -y --no-install-recommends install curl gcc 11 | RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git 12 | 13 | RUN apt-get -y install make 14 | 15 | RUN mkdir -p BUILD 16 | ENV HOME /build 17 | 18 | # Get android sdk, ndk, and rest of the stuff needed to build the android app. 19 | WORKDIR $HOME 20 | RUN mkdir android-sdk 21 | ENV ANDROID_HOME $HOME/android-sdk 22 | WORKDIR $ANDROID_HOME 23 | RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip 24 | RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0 sdk-tools-linux-3859397.zip' | sha256sum -c 25 | RUN unzip sdk-tools-linux-3859397.zip 26 | RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update 27 | RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-31' 28 | RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository' 29 | RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;23.1.7779620' 30 | RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools' 31 | RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3' 32 | 33 | ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools 34 | ENV ANDROID_SDK_ROOT /build/android-sdk 35 | 36 | # We need some version of Go new enough to support the "embed" package 37 | # to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go 38 | # version we need later, but otherwise this toolchain isn't used: 39 | RUN curl -L https://go.dev/dl/go1.17.5.linux-amd64.tar.gz | tar -C /usr/local -zxv 40 | RUN ln -s /usr/local/go/bin/go /usr/bin 41 | 42 | RUN mkdir -p $HOME/tailscale-android 43 | WORKDIR $HOME/tailscale-android 44 | 45 | # Preload Gradle 46 | COPY android/gradlew android/gradlew 47 | COPY android/gradle android/gradle 48 | RUN ./android/gradlew 49 | 50 | CMD /bin/bash 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Tailscale Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | # Use of this source code is governed by a BSD-style 3 | # license that can be found in the LICENSE file. 4 | 5 | DEBUG_APK=tailscale-debug.apk 6 | RELEASE_AAB=tailscale-release.aab 7 | APPID=com.tailscale.ipn 8 | AAR=android/libs/ipn.aar 9 | KEYSTORE=tailscale.jks 10 | KEYSTORE_ALIAS=tailscale 11 | TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) 12 | OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200) 13 | TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11) 14 | OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11) 15 | VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV) 16 | # Extract the long version build.gradle's versionName and strip quotes. 17 | VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle))) 18 | # Extract the x.y.z part for the short version. 19 | VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1) 20 | TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2) 21 | # Extract the version code from build.gradle. 22 | VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle)) 23 | VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1) 24 | 25 | TOOLCHAINREV=$(shell go run tailscale.com/cmd/printdep --go) 26 | TOOLCHAINDIR=${HOME}/.cache/tailscale-android-go-$(TOOLCHAINREV) 27 | TOOLCHAINSUM=$(shell $(TOOLCHAINDIR)/go/bin/go version >/dev/null && echo "okay" || echo "bad") 28 | TOOLCHAINWANT=okay 29 | export PATH := $(TOOLCHAINDIR)/go/bin:$(PATH) 30 | export GOROOT := # Unset 31 | 32 | all: $(APK) 33 | 34 | tag_release: 35 | sed -i'.bak' 's/versionCode [[:digit:]]\+/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle 36 | sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle 37 | git commit -sm "android: bump version code" android/build.gradle 38 | git tag -a "$(VERSION_LONG)" 39 | 40 | bumposs: toolchain 41 | GOPROXY=direct go get tailscale.com@main 42 | go mod tidy -compat=1.17 43 | 44 | toolchain: 45 | ifneq ($(TOOLCHAINWANT),$(TOOLCHAINSUM)) 46 | @echo want: $(TOOLCHAINWANT) 47 | @echo got: $(TOOLCHAINSUM) 48 | rm -rf ${HOME}/.cache/tailscale-android-go-* 49 | mkdir -p $(TOOLCHAINDIR) 50 | curl --silent -L $(shell go run tailscale.com/cmd/printdep --go-url) | tar -C $(TOOLCHAINDIR) -zx 51 | endif 52 | 53 | $(DEBUG_APK): toolchain 54 | mkdir -p android/libs 55 | go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale 56 | (cd android && ./gradlew test assemblePlayDebug) 57 | mv android/build/outputs/apk/play/debug/android-play-debug.apk $@ 58 | 59 | rundebug: $(DEBUG_APK) 60 | adb install -r $(DEBUG_APK) 61 | adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity 62 | 63 | # tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits. 64 | # This is effectively what the F-Droid build definition produces. 65 | # This is useful for testing on e.g. Amazon Fire Stick devices. 66 | tailscale-fdroid.apk: toolchain 67 | mkdir -p android/libs 68 | go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale 69 | (cd android && ./gradlew test assembleFdroidDebug) 70 | mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@ 71 | 72 | # This target is also used by the F-Droid builder. 73 | release_aar: toolchain 74 | release_aar: 75 | mkdir -p android/libs 76 | go run gioui.org/cmd/gogio -ldflags "-X tailscale.com/version.Long=$(VERSIONNAME) -X tailscale.com/version.Short=$(VERSIONNAME_SHORT) -X tailscale.com/version.GitCommit=$(TAILSCALE_COMMIT) -X tailscale.com/version.ExtraGitCommit=$(OUR_VERSION)" -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale 77 | 78 | $(RELEASE_AAB): release_aar 79 | (cd android && ./gradlew test bundlePlayRelease) 80 | mv ./android/build/outputs/bundle/playRelease/android-play-release.aab $@ 81 | 82 | release: $(RELEASE_AAB) 83 | jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS) 84 | 85 | install: $(DEBUG_APK) 86 | adb install -r $(DEBUG_APK) 87 | 88 | dockershell: 89 | docker build -t tailscale-android . 90 | docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android 91 | 92 | clean: 93 | rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR) 94 | 95 | .PHONY: all clean install $(DEBUG_APK) $(RELEASE_AAB) release_aar release bump_version dockershell 96 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional IP Rights Grant (Patents) 2 | 3 | "This implementation" means the copyrightable works distributed by 4 | Tailscale Inc. as part of the Tailscale project. 5 | 6 | Tailscale Inc. hereby grants to You a perpetual, worldwide, 7 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated 8 | in this section) patent license to make, have made, use, offer to 9 | sell, sell, import, transfer and otherwise run, modify and propagate 10 | the contents of this implementation of Tailscale, where such license 11 | applies only to those patent claims, both currently owned or 12 | controlled by Tailscale Inc. and acquired in the future, licensable 13 | by Tailscale Inc. that are necessarily infringed by this 14 | implementation of Tailscale. This grant does not include claims that 15 | would be infringed only as a consequence of further modification of 16 | this implementation. If you or your agent or exclusive licensee 17 | institute or order or agree to the institution of patent litigation 18 | against any entity (including a cross-claim or counterclaim in a 19 | lawsuit) alleging that this implementation of Tailscale or any code 20 | incorporated within this implementation of Tailscale constitutes 21 | direct or contributory patent infringement, or inducement of patent 22 | infringement, then any patent rights granted to you under this License 23 | for this implementation of Tailscale shall terminate as of the date 24 | such litigation is filed. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailscale Android Client 2 | 3 | https://tailscale.com 4 | 5 | Private WireGuard® networks made easy 6 | 7 | ## Overview 8 | 9 | This repository contains the open source Tailscale Android client. 10 | 11 | ## Using 12 | 13 | [Get it on F-Droid](https://f-droid.org/packages/com.tailscale.ipn/) 16 | [Get it on Google Play](https://play.google.com/store/apps/details?id=com.tailscale.ipn) 19 | 20 | ## Building 21 | 22 | [Go](https://golang.org), the [Android 23 | SDK](https://developer.android.com/studio/releases/platform-tools), 24 | the [Android NDK](https://developer.android.com/ndk) are required. 25 | 26 | ```sh 27 | $ make tailscale-debug.apk 28 | $ adb install -r tailscale-debug.apk 29 | ``` 30 | 31 | The `dockershell` target builds a container with the necessary 32 | dependencies and runs a shell inside it. 33 | 34 | ```sh 35 | $ make dockershell 36 | # make tailscale-debug.apk 37 | ``` 38 | 39 | If you have Nix 2.4 or later installed, a Nix development environment can 40 | be set up with 41 | 42 | ```sh 43 | $ alias nix='nix --extra-experimental-features "nix-command flakes"' 44 | $ nix develop 45 | ``` 46 | 47 | Use `make tag_release` to bump the Android version code, update the version 48 | name, and tag the current commit. 49 | 50 | We only guarantee to support the latest Go release and any Go beta or 51 | release candidate builds (currently Go 1.14) in module mode. It might 52 | work in earlier Go versions or in GOPATH mode, but we're making no 53 | effort to keep those working. 54 | 55 | ## Google Sign-In 56 | 57 | Google Sign-In support relies on configuring a [Google API Console 58 | project](https://developers.google.com/identity/sign-in/android/start-integrating) 59 | with the app identifier and [signing key 60 | hashes](https://developers.google.com/android/guides/client-auth). 61 | The official release uses the app identifier `com.tailscale.ipn`; 62 | custom builds should use a different identifier. 63 | 64 | ## Running in the Android emulator 65 | 66 | By default, the android emulator uses an older version of OpenGL ES, 67 | which results in a black screen when opening the Tailscale app. To fix 68 | this, with the emulator running: 69 | 70 | - Open the three-dots menu to access emulator settings 71 | - To to `Settings > Advanced` 72 | - Set "OpenGL ES API level" to "Renderer maximum (up to OpenGL ES 3.1)" 73 | - Close the emulator. 74 | - In Android Studio's emulator view (that lists all your emulated 75 | devices), hit the down arrow by the virtual device and select "Cold 76 | boot now" to restart the emulator from scratch. 77 | 78 | The Tailscale app should now render correctly. 79 | 80 | Additionally, there seems to be a bug that prevents using the 81 | system-level Google sign-in option (the one that pops up a 82 | system-level UI to select your Google account). You can work around 83 | this by selecting "Other" at the sign-in screen, and then selecting 84 | Google from the next screen. 85 | 86 | ## Developing on a Fire Stick TV 87 | 88 | On the Fire Stick: 89 | 90 | * Settings > My Fire TV > Developer Options > ADB Debugging > ON 91 | 92 | Then some useful commands: 93 | ``` 94 | adb connect 10.2.200.213:5555 95 | adb install -r tailscale-fdroid.apk 96 | adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity 97 | adb shell pm uninstall com.tailscale.ipn 98 | ``` 99 | 100 | ## Building on macOS 101 | 102 | To build from the CLI on macOS: 103 | 104 | 1. Install Android Studio 105 | 2. In Android Studio's home screen: "More Actions" > "SDK Manager", install NDK. 106 | 3. You can now close Android Studio, unless you want it to create virtual devices 107 | ("More Actions" > "Virtual Device Manager"). 108 | 4. Then, from CLI: 109 | 5. `export JAVA_HOME='/Applications/Android Studio.app/Contents/jre/Contents/Home'` 110 | 6. `export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk` 111 | 7. `make tailscale-fdroid.apk`, etc 112 | 113 | ## Bugs 114 | 115 | Please file any issues about this code or the hosted service on 116 | [the tailscale issue tracker](https://github.com/tailscale/tailscale/issues). 117 | 118 | ## Contributing 119 | 120 | `under_construction.gif` 121 | 122 | PRs welcome, but we are still working out our contribution process and 123 | tooling. 124 | 125 | We require [Developer Certificate of 126 | Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin) 127 | `Signed-off-by` lines in commits. 128 | 129 | ## About Us 130 | 131 | We are apenwarr, bradfitz, crawshaw, danderson, dfcarney, 132 | from Tailscale Inc. 133 | You can learn more about us from [our website](https://tailscale.com). 134 | 135 | WireGuard is a registered trademark of Jason A. Donenfeld. 136 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | jcenter() 5 | } 6 | dependencies { 7 | classpath 'com.android.tools.build:gradle:4.2.0' 8 | } 9 | } 10 | 11 | allprojects { 12 | repositories { 13 | google() 14 | jcenter() 15 | flatDir { 16 | dirs 'libs' 17 | } 18 | } 19 | } 20 | 21 | apply plugin: 'com.android.application' 22 | 23 | android { 24 | ndkVersion "23.1.7779620" 25 | compileSdkVersion 30 26 | defaultConfig { 27 | minSdkVersion 22 28 | targetSdkVersion 30 29 | versionCode 114 30 | versionName "1.29.0-t3c892d106-g42f688f1292" 31 | } 32 | compileOptions { 33 | sourceCompatibility 1.8 34 | targetCompatibility 1.8 35 | } 36 | flavorDimensions "version" 37 | productFlavors { 38 | fdroid { 39 | // The fdroid flavor contains only free dependencies and is suitable 40 | // for the F-Droid app store. 41 | } 42 | play { 43 | // The play flavor contains all features and is for the Play Store. 44 | } 45 | } 46 | } 47 | 48 | dependencies { 49 | implementation "androidx.core:core:1.2.0" 50 | implementation "androidx.browser:browser:1.2.0" 51 | implementation "androidx.security:security-crypto:1.1.0-alpha03" 52 | implementation ':ipn@aar' 53 | testCompile "junit:junit:4.12" 54 | 55 | // Non-free dependencies. 56 | playImplementation 'com.google.android.gms:play-services-auth:18.0.0' 57 | } 58 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | android.useAndroidX=true 2 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=3239b5ed86c3838a37d983ac100573f64c1f3fd8e1eb6c89fa5f9529b5ec091d 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /android/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | @rem Execute Gradle 88 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 89 | 90 | :end 91 | @rem End local scope for the variables with windows NT shell 92 | if "%ERRORLEVEL%"=="0" goto mainEnd 93 | 94 | :fail 95 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 96 | rem the _cmd.exe /c_ return code! 97 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 98 | exit /b 1 99 | 100 | :mainEnd 101 | if "%OS%"=="Windows_NT" endlocal 102 | 103 | :omega 104 | -------------------------------------------------------------------------------- /android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 59 | 60 | 61 | 62 | 63 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /android/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/App.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.app.Application; 8 | import android.app.Activity; 9 | import android.app.DownloadManager; 10 | import android.app.Fragment; 11 | import android.app.FragmentTransaction; 12 | import android.app.NotificationChannel; 13 | import android.app.PendingIntent; 14 | import android.app.UiModeManager; 15 | import android.content.BroadcastReceiver; 16 | import android.content.ContentResolver; 17 | import android.content.ContentValues; 18 | import android.content.Context; 19 | import android.content.Intent; 20 | import android.content.IntentFilter; 21 | import android.content.SharedPreferences; 22 | import android.content.pm.PackageManager; 23 | import android.content.pm.PackageInfo; 24 | import android.content.pm.Signature; 25 | import android.content.res.Configuration; 26 | import android.provider.MediaStore; 27 | import android.provider.Settings; 28 | import android.net.ConnectivityManager; 29 | import android.net.LinkProperties; 30 | import android.net.Network; 31 | import android.net.NetworkInfo; 32 | import android.net.NetworkRequest; 33 | import android.net.Uri; 34 | import android.net.VpnService; 35 | import android.view.View; 36 | import android.os.Build; 37 | import android.os.Environment; 38 | import android.os.Handler; 39 | import android.os.Looper; 40 | 41 | import android.Manifest; 42 | import android.webkit.MimeTypeMap; 43 | 44 | import java.io.IOException; 45 | import java.io.File; 46 | import java.io.FileOutputStream; 47 | 48 | import java.lang.StringBuilder; 49 | 50 | import java.net.InetAddress; 51 | import java.net.InterfaceAddress; 52 | import java.net.NetworkInterface; 53 | 54 | import java.security.GeneralSecurityException; 55 | 56 | import java.util.ArrayList; 57 | import java.util.Collections; 58 | import java.util.List; 59 | import java.util.Locale; 60 | 61 | import androidx.core.app.NotificationCompat; 62 | import androidx.core.app.NotificationManagerCompat; 63 | 64 | import androidx.core.content.ContextCompat; 65 | 66 | import androidx.security.crypto.EncryptedSharedPreferences; 67 | import androidx.security.crypto.MasterKey; 68 | 69 | import androidx.browser.customtabs.CustomTabsIntent; 70 | 71 | import org.gioui.Gio; 72 | 73 | public class App extends Application { 74 | private final static String PEER_TAG = "peer"; 75 | 76 | static final String STATUS_CHANNEL_ID = "tailscale-status"; 77 | static final int STATUS_NOTIFICATION_ID = 1; 78 | 79 | static final String NOTIFY_CHANNEL_ID = "tailscale-notify"; 80 | static final int NOTIFY_NOTIFICATION_ID = 2; 81 | 82 | private static final String FILE_CHANNEL_ID = "tailscale-files"; 83 | private static final int FILE_NOTIFICATION_ID = 3; 84 | 85 | private final static Handler mainHandler = new Handler(Looper.getMainLooper()); 86 | 87 | public DnsConfig dns = new DnsConfig(this); 88 | public DnsConfig getDnsConfigObj() { return this.dns; } 89 | 90 | @Override public void onCreate() { 91 | super.onCreate(); 92 | // Load and initialize the Go library. 93 | Gio.init(this); 94 | registerNetworkCallback(); 95 | 96 | createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT); 97 | createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW); 98 | createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT); 99 | 100 | } 101 | 102 | private void registerNetworkCallback() { 103 | ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE); 104 | cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() { 105 | private void reportConnectivityChange() { 106 | NetworkInfo active = cMgr.getActiveNetworkInfo(); 107 | // https://developer.android.com/training/monitoring-device-state/connectivity-status-type 108 | boolean isConnected = active != null && active.isConnectedOrConnecting(); 109 | onConnectivityChanged(isConnected); 110 | } 111 | 112 | @Override 113 | public void onLost(Network network) { 114 | super.onLost(network); 115 | this.reportConnectivityChange(); 116 | } 117 | 118 | @Override 119 | public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) { 120 | super.onLinkPropertiesChanged(network, linkProperties); 121 | this.reportConnectivityChange(); 122 | } 123 | }); 124 | } 125 | 126 | public void startVPN() { 127 | Intent intent = new Intent(this, IPNService.class); 128 | intent.setAction(IPNService.ACTION_CONNECT); 129 | startService(intent); 130 | } 131 | 132 | public void stopVPN() { 133 | Intent intent = new Intent(this, IPNService.class); 134 | intent.setAction(IPNService.ACTION_DISCONNECT); 135 | startService(intent); 136 | } 137 | 138 | // encryptToPref a byte array of data using the Jetpack Security 139 | // library and writes it to a global encrypted preference store. 140 | public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException { 141 | getEncryptedPrefs().edit().putString(prefKey, plaintext).commit(); 142 | } 143 | 144 | // decryptFromPref decrypts a encrypted preference using the Jetpack Security 145 | // library and returns the plaintext. 146 | public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException { 147 | return getEncryptedPrefs().getString(prefKey, null); 148 | } 149 | 150 | private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException { 151 | MasterKey key = new MasterKey.Builder(this) 152 | .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) 153 | .build(); 154 | 155 | return EncryptedSharedPreferences.create( 156 | this, 157 | "secret_shared_prefs", 158 | key, 159 | EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, 160 | EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 161 | ); 162 | } 163 | 164 | void setTileReady(boolean ready) { 165 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { 166 | return; 167 | } 168 | QuickToggleService.setReady(this, ready); 169 | } 170 | 171 | void setTileStatus(boolean status) { 172 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { 173 | return; 174 | } 175 | QuickToggleService.setStatus(this, status); 176 | } 177 | 178 | String getHostname() { 179 | String userConfiguredDeviceName = getUserConfiguredDeviceName(); 180 | if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName; 181 | 182 | return getModelName(); 183 | } 184 | 185 | String getModelName() { 186 | String manu = Build.MANUFACTURER; 187 | String model = Build.MODEL; 188 | // Strip manufacturer from model. 189 | int idx = model.toLowerCase().indexOf(manu.toLowerCase()); 190 | if (idx != -1) { 191 | model = model.substring(idx + manu.length()); 192 | model = model.trim(); 193 | } 194 | return manu + " " + model; 195 | } 196 | 197 | String getOSVersion() { 198 | return Build.VERSION.RELEASE; 199 | } 200 | 201 | // get user defined nickname from Settings 202 | // returns null if not available 203 | private String getUserConfiguredDeviceName() { 204 | String nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name"); 205 | String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name"); 206 | String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name"); 207 | 208 | if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth; 209 | if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth; 210 | if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice; 211 | return null; 212 | } 213 | 214 | private static boolean isEmpty(String str) { 215 | return str == null || str.length() == 0; 216 | } 217 | 218 | // attachPeer adds a Peer fragment for tracking the Activity 219 | // lifecycle. 220 | void attachPeer(Activity act) { 221 | act.runOnUiThread(new Runnable() { 222 | @Override public void run() { 223 | FragmentTransaction ft = act.getFragmentManager().beginTransaction(); 224 | ft.add(new Peer(), PEER_TAG); 225 | ft.commit(); 226 | act.getFragmentManager().executePendingTransactions(); 227 | } 228 | }); 229 | } 230 | 231 | boolean isChromeOS() { 232 | return getPackageManager().hasSystemFeature("android.hardware.type.pc"); 233 | } 234 | 235 | void prepareVPN(Activity act, int reqCode) { 236 | act.runOnUiThread(new Runnable() { 237 | @Override public void run() { 238 | Intent intent = VpnService.prepare(act); 239 | if (intent == null) { 240 | onVPNPrepared(); 241 | } else { 242 | startActivityForResult(act, intent, reqCode); 243 | } 244 | } 245 | }); 246 | } 247 | 248 | static void startActivityForResult(Activity act, Intent intent, int request) { 249 | Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG); 250 | f.startActivityForResult(intent, request); 251 | } 252 | 253 | void showURL(Activity act, String url) { 254 | act.runOnUiThread(new Runnable() { 255 | @Override public void run() { 256 | CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); 257 | int headerColor = 0xff496495; 258 | builder.setToolbarColor(headerColor); 259 | CustomTabsIntent intent = builder.build(); 260 | intent.launchUrl(act, Uri.parse(url)); 261 | } 262 | }); 263 | } 264 | 265 | // getPackageSignatureFingerprint returns the first package signing certificate, if any. 266 | byte[] getPackageCertificate() throws Exception { 267 | PackageInfo info; 268 | info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES); 269 | for (Signature signature : info.signatures) { 270 | return signature.toByteArray(); 271 | } 272 | return null; 273 | } 274 | 275 | void requestWriteStoragePermission(Activity act) { 276 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { 277 | // We can write files without permission. 278 | return; 279 | } 280 | if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { 281 | return; 282 | } 283 | act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT); 284 | } 285 | 286 | String insertMedia(String name, String mimeType) throws IOException { 287 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 288 | ContentResolver resolver = getContentResolver(); 289 | ContentValues contentValues = new ContentValues(); 290 | contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name); 291 | if (!"".equals(mimeType)) { 292 | contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); 293 | } 294 | Uri root = MediaStore.Files.getContentUri("external"); 295 | return resolver.insert(root, contentValues).toString(); 296 | } else { 297 | File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); 298 | dir.mkdirs(); 299 | File f = new File(dir, name); 300 | return Uri.fromFile(f).toString(); 301 | } 302 | } 303 | 304 | int openUri(String uri, String mode) throws IOException { 305 | ContentResolver resolver = getContentResolver(); 306 | return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd(); 307 | } 308 | 309 | void deleteUri(String uri) { 310 | ContentResolver resolver = getContentResolver(); 311 | resolver.delete(Uri.parse(uri), null, null); 312 | } 313 | 314 | public void notifyFile(String uri, String msg) { 315 | Intent viewIntent; 316 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 317 | viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri)); 318 | } else { 319 | // uri is a file:// which is not allowed to be shared outside the app. 320 | viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS); 321 | } 322 | PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT); 323 | NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID) 324 | .setSmallIcon(R.drawable.ic_notification) 325 | .setContentTitle("File received") 326 | .setContentText(msg) 327 | .setContentIntent(pending) 328 | .setAutoCancel(true) 329 | .setOnlyAlertOnce(true) 330 | .setPriority(NotificationCompat.PRIORITY_DEFAULT); 331 | 332 | NotificationManagerCompat nm = NotificationManagerCompat.from(this); 333 | nm.notify(FILE_NOTIFICATION_ID, builder.build()); 334 | } 335 | 336 | private void createNotificationChannel(String id, String name, int importance) { 337 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { 338 | return; 339 | } 340 | NotificationChannel channel = new NotificationChannel(id, name, importance); 341 | NotificationManagerCompat nm = NotificationManagerCompat.from(this); 342 | nm.createNotificationChannel(channel); 343 | } 344 | 345 | static native void onVPNPrepared(); 346 | private static native void onConnectivityChanged(boolean connected); 347 | static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes); 348 | static native void onWriteStorageGranted(); 349 | 350 | // Returns details of the interfaces in the system, encoded as a single string for ease 351 | // of JNI transfer over to the Go environment. 352 | // 353 | // Example: 354 | // rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64 355 | // dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64 356 | // wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24 357 | // r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64 358 | // rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64 359 | // r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64 360 | // rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64 361 | // lo 1 65536 true false true false false | ::1/128 127.0.0.1/8 362 | // v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32 363 | // 364 | // Where the fields are: 365 | // name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N; 366 | String getInterfacesAsString() { 367 | List interfaces; 368 | try { 369 | interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); 370 | } catch (Exception e) { 371 | return ""; 372 | } 373 | 374 | StringBuilder sb = new StringBuilder(""); 375 | for (NetworkInterface nif : interfaces) { 376 | try { 377 | // Android doesn't have a supportsBroadcast() but the Go net.Interface wants 378 | // one, so we say the interface has broadcast if it has multicast. 379 | sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(), 380 | nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(), 381 | nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast())); 382 | 383 | for (InterfaceAddress ia : nif.getInterfaceAddresses()) { 384 | // InterfaceAddress == hostname + "/" + IP 385 | String[] parts = ia.toString().split("/", 0); 386 | if (parts.length > 1) { 387 | sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength())); 388 | } 389 | } 390 | } catch (Exception e) { 391 | // TODO(dgentry) should log the exception not silently suppress it. 392 | continue; 393 | } 394 | sb.append("\n"); 395 | } 396 | 397 | return sb.toString(); 398 | } 399 | 400 | boolean isTV() { 401 | UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE); 402 | return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/DnsConfig.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.content.Context; 8 | import android.net.ConnectivityManager; 9 | import android.net.DhcpInfo; 10 | import android.net.LinkProperties; 11 | import android.net.Network; 12 | import android.net.NetworkCapabilities; 13 | import android.net.NetworkInfo; 14 | import android.net.wifi.WifiManager; 15 | 16 | import java.lang.reflect.Method; 17 | 18 | import java.net.InetAddress; 19 | 20 | import java.util.ArrayList; 21 | import java.util.List; 22 | import java.util.Locale; 23 | 24 | // Tailscale DNS Config retrieval 25 | // 26 | // Tailscale's DNS support can either override the local DNS servers with a set of servers 27 | // configured in the admin panel, or supplement the local DNS servers with additional 28 | // servers for specific domains like example.com.beta.tailscale.net. In the non-override mode, 29 | // we need to retrieve the current set of DNS servers from the platform. These will typically 30 | // be the DNS servers received from DHCP. 31 | // 32 | // Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100 33 | // but we still want to retrieve the underlying DNS servers received from DHCP. If we roam 34 | // from Wi-Fi to LTE, we want the DNS servers received from LTE. 35 | // 36 | // --------------------- Android 7 and later ----------------------------------------- 37 | // 38 | // ## getDnsConfigFromLinkProperties 39 | // Android provides a getAllNetworks interface in the ConnectivityManager. We walk through 40 | // each interface to pick the most appropriate one. 41 | // - If there is an Ethernet interface active we use that. 42 | // - If Wi-Fi is active we use that. 43 | // - If LTE is active we use that. 44 | // - We never use a VPN's DNS servers. That VPN is likely us. Even if not us, Android 45 | // only allows one VPN at a time so a different VPN's DNS servers won't be available 46 | // once Tailscale comes up. 47 | // 48 | // getAllNetworks() is used as the sole mechanism for retrieving the DNS config with 49 | // Android 7 and later. 50 | // 51 | // --------------------- Releases older than Android 7 ------------------------------- 52 | // 53 | // We support Tailscale back to Android 5. Android versions 5 and 6 supply a getAllNetworks() 54 | // implementation but it always returns an empty list. 55 | // 56 | // ## getDnsConfigFromLinkProperties with getActiveNetwork 57 | // ConnectivityManager also supports a getActiveNetwork() routine, which Android 5 and 6 do 58 | // return a value for. If Tailscale isn't up yet and we can get the Wi-Fi/LTE/etc DNS 59 | // config using getActiveNetwork(), we use that. 60 | // 61 | // Once Tailscale is up, getActiveNetwork() returns tailscale0 with DNS server 100.100.100.100 62 | // and that isn't useful. So we try two other mechanisms: 63 | // 64 | // ## getDnsServersFromSystemProperties 65 | // Android versions prior to 8 let us retrieve the actual system DNS servers from properties. 66 | // Later Android versions removed the properties and only return an empty string. 67 | // 68 | // We check the net.dns1 - net.dns4 DNS servers. If Tailscale is up the DNS server will be 69 | // 100.100.100.100, which isn't useful, but if we get something different we'll use that. 70 | // 71 | // getDnsServersFromSystemProperties can only retrieve the IPv4 or IPv6 addresses of the 72 | // configured DNS servers. We also want to know the DNS Search Domains configured, but 73 | // we have no way to retrieve this using these interfaces. We return an empty list of 74 | // search domains. Sorry. 75 | // 76 | // ## getDnsServersFromNetworkInfo 77 | // ConnectivityManager supports an older API called getActiveNetworkInfo to return the 78 | // active network interface. It doesn't handle VPNs, so the interface will always be Wi-Fi 79 | // or Cellular even if Tailscale is up. 80 | // 81 | // For Wi-Fi interfaces we retrieve the DHCP response from the WifiManager. For Cellular 82 | // interfaces we check for properties populated by most of the radio drivers. 83 | // 84 | // getDnsServersFromNetworkInfo does not have a way to retrieve the DNS Search Domains, 85 | // so we return an empty list. Additionally, these interfaces are so old that they only 86 | // support IPv4. We can't retrieve IPv6 DNS server addresses this way. 87 | 88 | public class DnsConfig { 89 | private Context ctx; 90 | 91 | public DnsConfig(Context ctx) { 92 | this.ctx = ctx; 93 | } 94 | 95 | // getDnsConfigAsString returns the current DNS configuration as a multiline string: 96 | // line[0] DNS server addresses separated by spaces 97 | // line[1] search domains separated by spaces 98 | // 99 | // For example: 100 | // 8.8.8.8 8.8.4.4 101 | // example.com 102 | // 103 | // an empty string means the current DNS configuration could not be retrieved. 104 | String getDnsConfigAsString() { 105 | String s = getDnsConfigFromLinkProperties(); 106 | if (!s.trim().isEmpty()) { 107 | return s; 108 | } 109 | if (android.os.Build.VERSION.SDK_INT >= 23) { 110 | // If ConnectivityManager.getAllNetworks() works, it is the 111 | // authoritative mechanism and we rely on it. The other methods 112 | // which follow involve more compromises. 113 | return ""; 114 | } 115 | 116 | s = getDnsServersFromSystemProperties(); 117 | if (!s.trim().isEmpty()) { 118 | return s; 119 | } 120 | return getDnsServersFromNetworkInfo(); 121 | } 122 | 123 | // getDnsConfigFromLinkProperties finds the DNS servers for each Network interface 124 | // returned by ConnectivityManager getAllNetworks().LinkProperties, and return the 125 | // one that (heuristically) would be the primary DNS servers. 126 | // 127 | // on a Nexus 4 with Android 5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1 128 | // on a Nexus 7 with Android 6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1 129 | // on a Pixel 3a with Android 12.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1\nlocaldomain 130 | // on a Pixel 3a with Android 12.0 on LTE: fd00:976a::9 fd00:976a::10 131 | // 132 | // One odd behavior noted on Pixel3a with Android 12: 133 | // With Wi-Fi already connected, starting Tailscale returned DNS servers 2602:248:7b4a:ff60::1 10.1.10.1 134 | // Turning off Wi-Fi and connecting LTE returned DNS servers fd00:976a::9 fd00:976a::10. 135 | // Turning Wi-Fi back on return DNS servers: 10.1.10.1. The IPv6 DNS server is gone. 136 | // This appears to be the ConnectivityManager behavior, not something we are doing. 137 | // 138 | // This implementation can work through Android 12 (SDK 30). In SDK 31 the 139 | // getAllNetworks() method is deprecated and we'll need to implement a 140 | // android.net.ConnectivityManager.NetworkCallback instead to monitor 141 | // link changes and track which DNS server to use. 142 | String getDnsConfigFromLinkProperties() { 143 | ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); 144 | if (cMgr == null) { 145 | return ""; 146 | } 147 | 148 | Network[] networks = cMgr.getAllNetworks(); 149 | if (networks == null) { 150 | // Android 6 and before often returns an empty list, but we 151 | // can try again with just the active network. 152 | // 153 | // Once Tailscale is connected, the active network will be Tailscale 154 | // which will have 100.100.100.100 for its DNS server. We reject 155 | // TYPE_VPN in getPreferabilityForNetwork, so it won't be returned. 156 | Network active = cMgr.getActiveNetwork(); 157 | if (active == null) { 158 | return ""; 159 | } 160 | networks = new Network[]{active}; 161 | } 162 | 163 | // getPreferabilityForNetwork returns an index into dnsConfigs from 0-3. 164 | String[] dnsConfigs = new String[]{"", "", "", ""}; 165 | for (Network network : networks) { 166 | int idx = getPreferabilityForNetwork(cMgr, network); 167 | if ((idx < 0) || (idx > 3)) { 168 | continue; 169 | } 170 | 171 | LinkProperties linkProp = cMgr.getLinkProperties(network); 172 | NetworkCapabilities nc = cMgr.getNetworkCapabilities(network); 173 | List dnsList = linkProp.getDnsServers(); 174 | StringBuilder sb = new StringBuilder(""); 175 | for (InetAddress ip : dnsList) { 176 | sb.append(ip.getHostAddress() + " "); 177 | } 178 | 179 | String d = linkProp.getDomains(); 180 | if (d != null) { 181 | sb.append("\n"); 182 | sb.append(d); 183 | } 184 | 185 | dnsConfigs[idx] = sb.toString(); 186 | } 187 | 188 | // return the lowest index DNS config which exists. If an Ethernet config 189 | // was found, return it. Otherwise if Wi-fi was found, return it. Etc. 190 | for (String s : dnsConfigs) { 191 | if (!s.trim().isEmpty()) { 192 | return s; 193 | } 194 | } 195 | 196 | return ""; 197 | } 198 | 199 | // getDnsServersFromSystemProperties returns DNS servers found in system properties. 200 | // On Android versions prior to Android 8, we can directly query the DNS 201 | // servers the system is using. More recent Android releases return empty strings. 202 | // 203 | // Once Tailscale is connected these properties will return 100.100.100.100, which we 204 | // suppress. 205 | // 206 | // on a Nexus 4 with Android 5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1 207 | // on a Nexus 7 with Android 6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1 208 | // on a Pixel 3a with Android 12.0 on wifi: 209 | // on a Pixel 3a with Android 12.0 on LTE: 210 | // 211 | // The list of DNS search domains does not appear to be available in system properties. 212 | String getDnsServersFromSystemProperties() { 213 | try { 214 | Class SystemProperties = Class.forName("android.os.SystemProperties"); 215 | Method method = SystemProperties.getMethod("get", String.class); 216 | List servers = new ArrayList(); 217 | for (String name : new String[]{"net.dns1", "net.dns2", "net.dns3", "net.dns4"}) { 218 | String value = (String) method.invoke(null, name); 219 | if (value != null && !value.isEmpty() && 220 | !value.equals("100.100.100.100") && 221 | !servers.contains(value)) { 222 | servers.add(value); 223 | } 224 | } 225 | return String.join(" ", servers); 226 | } catch (Exception e) { 227 | return ""; 228 | } 229 | } 230 | 231 | 232 | public String intToInetString(int hostAddress) { 233 | return String.format(java.util.Locale.ROOT, "%d.%d.%d.%d", 234 | (0xff & hostAddress), 235 | (0xff & (hostAddress >> 8)), 236 | (0xff & (hostAddress >> 16)), 237 | (0xff & (hostAddress >> 24))); 238 | } 239 | 240 | // getDnsServersFromNetworkInfo retrieves DNS servers using ConnectivityManager 241 | // getActiveNetworkInfo() plus interface-specific mechanisms to retrieve the DNS servers. 242 | // Only IPv4 DNS servers are supported by this mechanism, neither the WifiManager nor the 243 | // interface-specific dns properties appear to populate IPv6 DNS server addresses. 244 | // 245 | // on a Nexus 4 with Android 5.1 on wifi: 10.1.10.1 246 | // on a Nexus 7 with Android 6.0 on wifi: 10.1.10.1 247 | // on a Pixel-3a with Android 12.0 on wifi: 10.1.10.1 248 | // on a Pixel-3a with Android 12.0 on LTE: 249 | // 250 | // The list of DNS search domains is not available in this way. 251 | String getDnsServersFromNetworkInfo() { 252 | ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); 253 | if (cMgr == null) { 254 | return ""; 255 | } 256 | 257 | NetworkInfo info = cMgr.getActiveNetworkInfo(); 258 | if (info == null) { 259 | return ""; 260 | } 261 | 262 | Class SystemProperties; 263 | Method method; 264 | 265 | try { 266 | SystemProperties = Class.forName("android.os.SystemProperties"); 267 | method = SystemProperties.getMethod("get", String.class); 268 | } catch (Exception e) { 269 | return ""; 270 | } 271 | 272 | List servers = new ArrayList(); 273 | 274 | switch(info.getType()) { 275 | case ConnectivityManager.TYPE_WIFI: 276 | case ConnectivityManager.TYPE_WIMAX: 277 | for (String name : new String[]{ 278 | "net.wifi0.dns1", "net.wifi0.dns2", "net.wifi0.dns3", "net.wifi0.dns4", 279 | "net.wlan0.dns1", "net.wlan0.dns2", "net.wlan0.dns3", "net.wlan0.dns4", 280 | "net.eth0.dns1", "net.eth0.dns2", "net.eth0.dns3", "net.eth0.dns4", 281 | "dhcp.wlan0.dns1", "dhcp.wlan0.dns2", "dhcp.wlan0.dns3", "dhcp.wlan0.dns4", 282 | "dhcp.tiwlan0.dns1", "dhcp.tiwlan0.dns2", "dhcp.tiwlan0.dns3", "dhcp.tiwlan0.dns4"}) { 283 | try { 284 | String value = (String) method.invoke(null, name); 285 | if (value != null && !value.isEmpty() && !servers.contains(value)) { 286 | servers.add(value); 287 | } 288 | } catch (Exception e) { 289 | continue; 290 | } 291 | } 292 | 293 | WifiManager wMgr = (WifiManager) ctx.getSystemService(Context.WIFI_SERVICE); 294 | if (wMgr != null) { 295 | DhcpInfo dhcp = wMgr.getDhcpInfo(); 296 | if (dhcp.dns1 != 0) { 297 | String value = intToInetString(dhcp.dns1); 298 | if (value != null && !value.isEmpty() && !servers.contains(value)) { 299 | servers.add(value); 300 | } 301 | } 302 | if (dhcp.dns2 != 0) { 303 | String value = intToInetString(dhcp.dns2); 304 | if (value != null && !value.isEmpty() && !servers.contains(value)) { 305 | servers.add(value); 306 | } 307 | } 308 | } 309 | return String.join(" ", servers); 310 | case ConnectivityManager.TYPE_MOBILE: 311 | case ConnectivityManager.TYPE_MOBILE_HIPRI: 312 | for (String name : new String[]{ 313 | "net.rmnet0.dns1", "net.rmnet0.dns2", "net.rmnet0.dns3", "net.rmnet0.dns4", 314 | "net.rmnet1.dns1", "net.rmnet1.dns2", "net.rmnet1.dns3", "net.rmnet1.dns4", 315 | "net.rmnet2.dns1", "net.rmnet2.dns2", "net.rmnet2.dns3", "net.rmnet2.dns4", 316 | "net.rmnet3.dns1", "net.rmnet3.dns2", "net.rmnet3.dns3", "net.rmnet3.dns4", 317 | "net.rmnet4.dns1", "net.rmnet4.dns2", "net.rmnet4.dns3", "net.rmnet4.dns4", 318 | "net.rmnet5.dns1", "net.rmnet5.dns2", "net.rmnet5.dns3", "net.rmnet5.dns4", 319 | "net.rmnet6.dns1", "net.rmnet6.dns2", "net.rmnet6.dns3", "net.rmnet6.dns4", 320 | "net.rmnet7.dns1", "net.rmnet7.dns2", "net.rmnet7.dns3", "net.rmnet7.dns4", 321 | "net.pdp0.dns1", "net.pdp0.dns2", "net.pdp0.dns3", "net.pdp0.dns4", 322 | "net.pdpbr0.dns1", "net.pdpbr0.dns2", "net.pdpbr0.dns3", "net.pdpbr0.dns4"}) { 323 | try { 324 | String value = (String) method.invoke(null, name); 325 | if (value != null && !value.isEmpty() && !servers.contains(value)) { 326 | servers.add(value); 327 | } 328 | } catch (Exception e) { 329 | continue; 330 | } 331 | 332 | } 333 | } 334 | 335 | return ""; 336 | } 337 | 338 | // getPreferabilityForNetwork is a utility routine which implements a priority for 339 | // different types of network transport, used in a heuristic to pick DNS servers to use. 340 | int getPreferabilityForNetwork(ConnectivityManager cMgr, Network network) { 341 | NetworkCapabilities nc = cMgr.getNetworkCapabilities(network); 342 | 343 | if (nc == null) { 344 | return -1; 345 | } 346 | if (nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { 347 | // tun0 has both VPN and WIFI set, have to check VPN first and return. 348 | return -1; 349 | } 350 | 351 | if (nc.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) { 352 | return 0; 353 | } else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { 354 | return 1; 355 | } else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { 356 | return 2; 357 | } else { 358 | return 3; 359 | } 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/IPNActivity.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.app.Activity; 8 | import android.content.res.AssetFileDescriptor; 9 | import android.content.res.Configuration; 10 | import android.content.Intent; 11 | import android.database.Cursor; 12 | import android.os.Bundle; 13 | import android.provider.OpenableColumns; 14 | import android.net.Uri; 15 | import android.content.pm.PackageManager; 16 | 17 | import java.util.List; 18 | import java.util.ArrayList; 19 | 20 | import org.gioui.GioView; 21 | 22 | public final class IPNActivity extends Activity { 23 | final static int WRITE_STORAGE_RESULT = 1000; 24 | 25 | private GioView view; 26 | 27 | @Override public void onCreate(Bundle state) { 28 | super.onCreate(state); 29 | view = new GioView(this); 30 | setContentView(view); 31 | handleIntent(); 32 | } 33 | 34 | @Override public void onNewIntent(Intent i) { 35 | setIntent(i); 36 | handleIntent(); 37 | } 38 | 39 | private void handleIntent() { 40 | Intent it = getIntent(); 41 | String act = it.getAction(); 42 | String[] texts; 43 | Uri[] uris; 44 | if (Intent.ACTION_SEND.equals(act)) { 45 | uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)}; 46 | texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)}; 47 | } else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) { 48 | List extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 49 | uris = extraUris.toArray(new Uri[0]); 50 | texts = new String[uris.length]; 51 | } else { 52 | return; 53 | } 54 | String mime = it.getType(); 55 | int nitems = uris.length; 56 | String[] items = new String[nitems]; 57 | String[] mimes = new String[nitems]; 58 | int[] types = new int[nitems]; 59 | String[] names = new String[nitems]; 60 | long[] sizes = new long[nitems]; 61 | int nfiles = 0; 62 | for (int i = 0; i < uris.length; i++) { 63 | String text = texts[i]; 64 | Uri uri = uris[i]; 65 | if (text != null) { 66 | types[nfiles] = 1; // FileTypeText 67 | names[nfiles] = "file.txt"; 68 | mimes[nfiles] = mime; 69 | items[nfiles] = text; 70 | // Determined by len(text) in Go to eliminate UTF-8 encoding differences. 71 | sizes[nfiles] = 0; 72 | nfiles++; 73 | } else if (uri != null) { 74 | Cursor c = getContentResolver().query(uri, null, null, null, null); 75 | if (c == null) { 76 | // Ignore files we have no permission to access. 77 | continue; 78 | } 79 | int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); 80 | int sizeCol = c.getColumnIndex(OpenableColumns.SIZE); 81 | c.moveToFirst(); 82 | String name = c.getString(nameCol); 83 | long size = c.getLong(sizeCol); 84 | types[nfiles] = 2; // FileTypeURI 85 | mimes[nfiles] = mime; 86 | items[nfiles] = uri.toString(); 87 | names[nfiles] = name; 88 | sizes[nfiles] = size; 89 | nfiles++; 90 | } 91 | } 92 | App.onShareIntent(nfiles, types, mimes, items, names, sizes); 93 | } 94 | 95 | @Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) { 96 | switch (reqCode) { 97 | case WRITE_STORAGE_RESULT: 98 | if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) { 99 | App.onWriteStorageGranted(); 100 | } 101 | } 102 | } 103 | 104 | @Override public void onDestroy() { 105 | view.destroy(); 106 | super.onDestroy(); 107 | } 108 | 109 | @Override public void onStart() { 110 | super.onStart(); 111 | view.start(); 112 | } 113 | 114 | @Override public void onStop() { 115 | view.stop(); 116 | super.onStop(); 117 | } 118 | 119 | @Override public void onConfigurationChanged(Configuration c) { 120 | super.onConfigurationChanged(c); 121 | view.configurationChanged(); 122 | } 123 | 124 | @Override public void onLowMemory() { 125 | super.onLowMemory(); 126 | view.onLowMemory(); 127 | } 128 | 129 | @Override public void onBackPressed() { 130 | if (!view.backPressed()) 131 | super.onBackPressed(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/IPNService.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.os.Build; 8 | import android.app.PendingIntent; 9 | import android.content.Intent; 10 | import android.content.pm.PackageManager; 11 | import android.net.VpnService; 12 | import android.system.OsConstants; 13 | 14 | import org.gioui.GioActivity; 15 | 16 | import androidx.core.app.NotificationCompat; 17 | import androidx.core.app.NotificationManagerCompat; 18 | 19 | public class IPNService extends VpnService { 20 | public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT"; 21 | public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT"; 22 | 23 | @Override public int onStartCommand(Intent intent, int flags, int startId) { 24 | if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) { 25 | close(); 26 | return START_NOT_STICKY; 27 | } 28 | connect(); 29 | return START_STICKY; 30 | } 31 | 32 | private void close() { 33 | stopForeground(true); 34 | disconnect(); 35 | } 36 | 37 | @Override public void onDestroy() { 38 | close(); 39 | super.onDestroy(); 40 | } 41 | 42 | @Override public void onRevoke() { 43 | close(); 44 | super.onRevoke(); 45 | } 46 | 47 | private PendingIntent configIntent() { 48 | return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); 49 | } 50 | 51 | private void disallowApp(VpnService.Builder b, String name) { 52 | try { 53 | b.addDisallowedApplication(name); 54 | } catch (PackageManager.NameNotFoundException e) { 55 | return; 56 | } 57 | } 58 | 59 | protected VpnService.Builder newBuilder() { 60 | VpnService.Builder b = new VpnService.Builder() 61 | .setConfigureIntent(configIntent()) 62 | .allowFamily(OsConstants.AF_INET) 63 | .allowFamily(OsConstants.AF_INET6); 64 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 65 | b.setMetered(false); // Inherit the metered status from the underlying networks. 66 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) 67 | b.setUnderlyingNetworks(null); // Use all available networks. 68 | 69 | // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 70 | this.disallowApp(b, "com.google.android.apps.messaging"); 71 | 72 | // Stadia https://github.com/tailscale/tailscale/issues/3460 73 | this.disallowApp(b, "com.google.stadia.android"); 74 | 75 | // Android Auto https://github.com/tailscale/tailscale/issues/3828 76 | this.disallowApp(b, "com.google.android.projection.gearhead"); 77 | 78 | return b; 79 | } 80 | 81 | public void notify(String title, String message) { 82 | NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID) 83 | .setSmallIcon(R.drawable.ic_notification) 84 | .setContentTitle(title) 85 | .setContentText(message) 86 | .setContentIntent(configIntent()) 87 | .setAutoCancel(true) 88 | .setOnlyAlertOnce(true) 89 | .setPriority(NotificationCompat.PRIORITY_DEFAULT); 90 | 91 | NotificationManagerCompat nm = NotificationManagerCompat.from(this); 92 | nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build()); 93 | } 94 | 95 | public void updateStatusNotification(String title, String message) { 96 | NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID) 97 | .setSmallIcon(R.drawable.ic_notification) 98 | .setContentTitle(title) 99 | .setContentText(message) 100 | .setContentIntent(configIntent()) 101 | .setPriority(NotificationCompat.PRIORITY_LOW); 102 | 103 | startForeground(App.STATUS_NOTIFICATION_ID, builder.build()); 104 | } 105 | 106 | private native void connect(); 107 | private native void disconnect(); 108 | } 109 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/Peer.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.app.Activity; 8 | import android.app.Fragment; 9 | import android.content.Intent; 10 | 11 | public class Peer extends Fragment { 12 | @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { 13 | onActivityResult0(getActivity(), requestCode, resultCode); 14 | } 15 | 16 | private static native void onActivityResult0(Activity act, int reqCode, int resCode); 17 | } 18 | -------------------------------------------------------------------------------- /android/src/main/java/com/tailscale/ipn/QuickToggleService.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.content.Context; 8 | import android.content.ComponentName; 9 | import android.content.Intent; 10 | import android.service.quicksettings.Tile; 11 | import android.service.quicksettings.TileService; 12 | 13 | import java.util.concurrent.atomic.AtomicReference; 14 | import java.util.concurrent.atomic.AtomicBoolean; 15 | 16 | public class QuickToggleService extends TileService { 17 | // lock protects the static fields below it. 18 | private static Object lock = new Object(); 19 | // Active tracks whether the VPN is active. 20 | private static boolean active; 21 | // Ready tracks whether the tailscale backend is 22 | // ready to switch on/off. 23 | private static boolean ready; 24 | // currentTile tracks getQsTile while service is listening. 25 | private static Tile currentTile; 26 | 27 | @Override public void onStartListening() { 28 | synchronized (lock) { 29 | currentTile = getQsTile(); 30 | } 31 | updateTile(); 32 | } 33 | 34 | @Override public void onStopListening() { 35 | synchronized (lock) { 36 | currentTile = null; 37 | } 38 | } 39 | 40 | @Override public void onClick() { 41 | boolean r; 42 | synchronized (lock) { 43 | r = ready; 44 | } 45 | if (r) { 46 | onTileClick(); 47 | } else { 48 | // Start main activity. 49 | Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); 50 | startActivityAndCollapse(i); 51 | } 52 | } 53 | 54 | private static void updateTile() { 55 | Tile t; 56 | boolean act; 57 | synchronized (lock) { 58 | t = currentTile; 59 | act = active && ready; 60 | } 61 | if (t == null) { 62 | return; 63 | } 64 | t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE); 65 | t.updateTile(); 66 | } 67 | 68 | static void setReady(Context ctx, boolean rdy) { 69 | synchronized (lock) { 70 | ready = rdy; 71 | } 72 | updateTile(); 73 | } 74 | 75 | static void setStatus(Context ctx, boolean act) { 76 | synchronized (lock) { 77 | active = act; 78 | } 79 | updateTile(); 80 | } 81 | 82 | private static native void onTileClick(); 83 | } 84 | -------------------------------------------------------------------------------- /android/src/main/res/drawable-hdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/drawable-hdpi/ic_notification.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-mdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/drawable-mdpi/ic_notification.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xhdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/drawable-xhdpi/ic_notification.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xhdpi/tv_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/drawable-xhdpi/tv_banner.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xxhdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/drawable-xxhdpi/ic_notification.png -------------------------------------------------------------------------------- /android/src/main/res/drawable-xxxhdpi/ic_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/drawable-xxxhdpi/ic_notification.png -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 8 | 11 | 14 | 17 | 20 | 23 | 26 | 29 | 32 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /android/src/main/res/drawable/ic_tile.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 13 | 16 | 19 | 22 | 25 | 28 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1F2125 4 | -------------------------------------------------------------------------------- /android/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tailscale 4 | Tailscale 5 | 6 | -------------------------------------------------------------------------------- /android/src/play/java/com/tailscale/ipn/Google.java: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package com.tailscale.ipn; 6 | 7 | import android.app.Activity; 8 | import android.content.Intent; 9 | import android.content.Context; 10 | 11 | import com.google.android.gms.auth.api.signin.GoogleSignIn; 12 | import com.google.android.gms.auth.api.signin.GoogleSignInAccount; 13 | import com.google.android.gms.auth.api.signin.GoogleSignInClient; 14 | import com.google.android.gms.auth.api.signin.GoogleSignInOptions; 15 | 16 | // Google implements helpers for Google services. 17 | public final class Google { 18 | static String getIdTokenForActivity(Activity act) { 19 | GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(act); 20 | return acc.getIdToken(); 21 | } 22 | 23 | static void googleSignIn(Activity act, String serverOAuthID, int reqCode) { 24 | act.runOnUiThread(new Runnable() { 25 | @Override public void run() { 26 | GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) 27 | .requestIdToken(serverOAuthID) 28 | .requestEmail() 29 | .build(); 30 | GoogleSignInClient client = GoogleSignIn.getClient(act, gso); 31 | Intent signInIntent = client.getSignInIntent(); 32 | App.startActivityForResult(act, signInIntent, reqCode); 33 | } 34 | }); 35 | } 36 | 37 | static void googleSignOut(Context ctx) { 38 | GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) 39 | .build(); 40 | GoogleSignInClient client = GoogleSignIn.getClient(ctx, gso); 41 | client.signOut(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /android/src/test/java/com/tailscale/ipn/DnsConfigTest.java: -------------------------------------------------------------------------------- 1 | import org.junit.Before; 2 | import org.junit.Test; 3 | 4 | import static org.junit.Assert.assertEquals; 5 | import com.tailscale.ipn.DnsConfig; 6 | 7 | public class DnsConfigTest { 8 | DnsConfig dns; 9 | 10 | @Before 11 | public void setup() { 12 | dns = new DnsConfig(null); 13 | } 14 | 15 | @Test 16 | public void dnsConfig_intToInetStringTest() { 17 | assertEquals(dns.intToInetString(0x0101a8c0), "192.168.1.1"); 18 | assertEquals(dns.intToInetString(0x04030201), "1.2.3.4"); 19 | assertEquals(dns.intToInetString(0), "0.0.0.0"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cmd/tailscale/backend.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "log" 11 | "net/http" 12 | "path/filepath" 13 | "reflect" 14 | "strings" 15 | "time" 16 | 17 | "github.com/tailscale/tailscale-android/jni" 18 | "golang.org/x/sys/unix" 19 | "golang.zx2c4.com/wireguard/tun" 20 | "inet.af/netaddr" 21 | "tailscale.com/ipn" 22 | "tailscale.com/ipn/ipnlocal" 23 | "tailscale.com/logpolicy" 24 | "tailscale.com/logtail" 25 | "tailscale.com/logtail/filch" 26 | "tailscale.com/net/dns" 27 | "tailscale.com/net/tsdial" 28 | "tailscale.com/smallzstd" 29 | "tailscale.com/types/logger" 30 | "tailscale.com/util/dnsname" 31 | "tailscale.com/wgengine" 32 | "tailscale.com/wgengine/netstack" 33 | "tailscale.com/wgengine/router" 34 | ) 35 | 36 | type backend struct { 37 | engine wgengine.Engine 38 | backend *ipnlocal.LocalBackend 39 | devices *multiTUN 40 | settings settingsFunc 41 | lastCfg *router.Config 42 | lastDNSCfg *dns.OSConfig 43 | 44 | logIDPublic string 45 | 46 | // avoidEmptyDNS controls whether to use fallback nameservers 47 | // when no nameservers are provided by Tailscale. 48 | avoidEmptyDNS bool 49 | 50 | jvm *jni.JVM 51 | appCtx jni.Object 52 | } 53 | 54 | type settingsFunc func(*router.Config, *dns.OSConfig) error 55 | 56 | const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go 57 | 58 | const ( 59 | logPrefKey = "privatelogid" 60 | loginMethodPrefKey = "loginmethod" 61 | customLoginServerPrefKey = "customloginserver" 62 | ) 63 | 64 | const ( 65 | loginMethodGoogle = "google" 66 | loginMethodWeb = "web" 67 | ) 68 | 69 | // googleDnsServers are used on ChromeOS, where an empty VpnBuilder DNS setting results 70 | // in erasing the platform DNS servers. The developer docs say this is not supposed to happen, 71 | // but nonetheless it does. 72 | var googleDnsServers = []netaddr.IP{netaddr.MustParseIP("8.8.8.8"), netaddr.MustParseIP("8.8.4.4"), 73 | netaddr.MustParseIP("2001:4860:4860::8888"), netaddr.MustParseIP("2001:4860:4860::8844"), 74 | } 75 | 76 | // errVPNNotPrepared is used when VPNService.Builder.establish returns 77 | // null, either because the VPNService is not yet prepared or because 78 | // VPN status was revoked. 79 | var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked") 80 | 81 | func newBackend(dataDir string, jvm *jni.JVM, appCtx jni.Object, store *stateStore, 82 | settings settingsFunc) (*backend, error) { 83 | 84 | logf := logger.RusagePrefixLog(log.Printf) 85 | b := &backend{ 86 | jvm: jvm, 87 | devices: newTUNDevices(), 88 | settings: settings, 89 | appCtx: appCtx, 90 | } 91 | var logID logtail.PrivateID 92 | logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000")) 93 | storedLogID, err := store.read(logPrefKey) 94 | // In all failure cases we ignore any errors and continue with the dead value above. 95 | if err != nil || storedLogID == nil { 96 | // Read failed or there was no previous log id. 97 | newLogID, err := logtail.NewPrivateID() 98 | if err == nil { 99 | logID = newLogID 100 | enc, err := newLogID.MarshalText() 101 | if err == nil { 102 | store.write(logPrefKey, enc) 103 | } 104 | } 105 | } else { 106 | logID.UnmarshalText([]byte(storedLogID)) 107 | } 108 | b.SetupLogs(dataDir, logID) 109 | dialer := new(tsdial.Dialer) 110 | cb := &router.CallbackRouter{ 111 | SetBoth: b.setCfg, 112 | SplitDNS: false, 113 | GetBaseConfigFunc: b.getDNSBaseConfig, 114 | } 115 | engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ 116 | Tun: b.devices, 117 | Router: cb, 118 | DNS: cb, 119 | Dialer: dialer, 120 | }) 121 | if err != nil { 122 | return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err) 123 | } 124 | b.logIDPublic = logID.Public().String() 125 | tunDev, magicConn, dnsMgr, ok := engine.(wgengine.InternalsGetter).GetInternals() 126 | if !ok { 127 | return nil, fmt.Errorf("%T is not a wgengine.InternalsGetter", engine) 128 | } 129 | ns, err := netstack.Create(logf, tunDev, engine, magicConn, dialer, dnsMgr) 130 | if err != nil { 131 | return nil, fmt.Errorf("netstack.Create: %w", err) 132 | } 133 | ns.ProcessLocalIPs = false // let Android kernel handle it; VpnBuilder sets this up 134 | ns.ProcessSubnets = true // for Android-being-an-exit-node support 135 | lb, err := ipnlocal.NewLocalBackend(logf, b.logIDPublic, store, dialer, engine, 0) 136 | if err != nil { 137 | engine.Close() 138 | return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) 139 | } 140 | ns.SetLocalBackend(lb) 141 | if err := ns.Start(); err != nil { 142 | return nil, fmt.Errorf("startNetstack: %w", err) 143 | } 144 | b.engine = engine 145 | b.backend = lb 146 | return b, nil 147 | } 148 | 149 | func (b *backend) Start(notify func(n ipn.Notify)) error { 150 | b.backend.SetNotifyCallback(notify) 151 | return b.backend.Start(ipn.Options{ 152 | StateKey: "ipn-android", 153 | }) 154 | } 155 | 156 | func (b *backend) LinkChange() { 157 | if b.engine != nil { 158 | b.engine.LinkChange(false) 159 | } 160 | } 161 | 162 | func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error { 163 | return b.settings(rcfg, dcfg) 164 | } 165 | 166 | func (b *backend) updateTUN(service jni.Object, rcfg *router.Config, dcfg *dns.OSConfig) error { 167 | if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { 168 | return nil 169 | } 170 | 171 | // Close previous tunnel(s). 172 | // This is necessary for ChromeOS, native Android devices 173 | // seem to handle seamless handover between tunnels correctly. 174 | // 175 | // TODO(eliasnaur): If seamless handover becomes a desirable feature, skip 176 | // the closing on ChromeOS. 177 | b.CloseTUNs() 178 | 179 | if len(rcfg.LocalAddrs) == 0 { 180 | return nil 181 | } 182 | err := jni.Do(b.jvm, func(env *jni.Env) error { 183 | cls := jni.GetObjectClass(env, service) 184 | // Construct a VPNService.Builder. IPNService.newBuilder calls 185 | // setConfigureIntent, and allowFamily for both IPv4 and IPv6. 186 | m := jni.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;") 187 | builder, err := jni.CallObjectMethod(env, service, m) 188 | if err != nil { 189 | return fmt.Errorf("IPNService.newBuilder: %v", err) 190 | } 191 | bcls := jni.GetObjectClass(env, builder) 192 | 193 | // builder.setMtu. 194 | setMtu := jni.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;") 195 | const mtu = defaultMTU 196 | if _, err := jni.CallObjectMethod(env, builder, setMtu, jni.Value(mtu)); err != nil { 197 | return fmt.Errorf("VpnService.Builder.setMtu: %v", err) 198 | } 199 | 200 | // builder.addDnsServer 201 | addDnsServer := jni.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;") 202 | // builder.addSearchDomain. 203 | addSearchDomain := jni.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;") 204 | if dcfg != nil { 205 | nameservers := dcfg.Nameservers 206 | if b.avoidEmptyDNS && len(nameservers) == 0 { 207 | nameservers = googleDnsServers 208 | } 209 | for _, dns := range nameservers { 210 | _, err = jni.CallObjectMethod(env, 211 | builder, 212 | addDnsServer, 213 | jni.Value(jni.JavaString(env, dns.String())), 214 | ) 215 | if err != nil { 216 | return fmt.Errorf("VpnService.Builder.addDnsServer(%v): %v", dns, err) 217 | } 218 | } 219 | 220 | for _, dom := range dcfg.SearchDomains { 221 | _, err = jni.CallObjectMethod(env, 222 | builder, 223 | addSearchDomain, 224 | jni.Value(jni.JavaString(env, dom.WithoutTrailingDot())), 225 | ) 226 | if err != nil { 227 | return fmt.Errorf("VpnService.Builder.addSearchDomain(%v): %v", dom, err) 228 | } 229 | } 230 | } 231 | 232 | // builder.addRoute. 233 | addRoute := jni.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;") 234 | for _, route := range rcfg.Routes { 235 | // Normalize route address; Builder.addRoute does not accept non-zero masked bits. 236 | route = route.Masked() 237 | _, err = jni.CallObjectMethod(env, 238 | builder, 239 | addRoute, 240 | jni.Value(jni.JavaString(env, route.IP().String())), 241 | jni.Value(route.Bits()), 242 | ) 243 | if err != nil { 244 | return fmt.Errorf("VpnService.Builder.addRoute(%v): %v", route, err) 245 | } 246 | } 247 | 248 | // builder.addAddress. 249 | addAddress := jni.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;") 250 | for _, addr := range rcfg.LocalAddrs { 251 | _, err = jni.CallObjectMethod(env, 252 | builder, 253 | addAddress, 254 | jni.Value(jni.JavaString(env, addr.IP().String())), 255 | jni.Value(addr.Bits()), 256 | ) 257 | if err != nil { 258 | return fmt.Errorf("VpnService.Builder.addAddress(%v): %v", addr, err) 259 | } 260 | } 261 | 262 | // builder.establish. 263 | establish := jni.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;") 264 | parcelFD, err := jni.CallObjectMethod(env, builder, establish) 265 | if err != nil { 266 | return fmt.Errorf("VpnService.Builder.establish: %v", err) 267 | } 268 | if parcelFD == 0 { 269 | return errVPNNotPrepared 270 | } 271 | 272 | // detachFd. 273 | parcelCls := jni.GetObjectClass(env, parcelFD) 274 | detachFd := jni.GetMethodID(env, parcelCls, "detachFd", "()I") 275 | tunFD, err := jni.CallIntMethod(env, parcelFD, detachFd) 276 | if err != nil { 277 | return fmt.Errorf("detachFd: %v", err) 278 | } 279 | 280 | // Create TUN device. 281 | tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD)) 282 | if err != nil { 283 | unix.Close(int(tunFD)) 284 | return err 285 | } 286 | 287 | b.devices.add(tunDev) 288 | 289 | return nil 290 | }) 291 | if err != nil { 292 | b.lastCfg = nil 293 | b.CloseTUNs() 294 | return err 295 | } 296 | b.lastCfg = rcfg 297 | b.lastDNSCfg = dcfg 298 | return nil 299 | } 300 | 301 | // CloseVPN closes any active TUN devices. 302 | func (b *backend) CloseTUNs() { 303 | b.lastCfg = nil 304 | b.devices.Shutdown() 305 | } 306 | 307 | // SetupLogs sets up remote logging. 308 | func (b *backend) SetupLogs(logDir string, logID logtail.PrivateID) { 309 | logcfg := logtail.Config{ 310 | Collection: "tailnode.log.tailscale.io", 311 | PrivateID: logID, 312 | Stderr: log.Writer(), 313 | NewZstdEncoder: func() logtail.Encoder { 314 | w, err := smallzstd.NewEncoder(nil) 315 | if err != nil { 316 | panic(err) 317 | } 318 | return w 319 | }, 320 | HTTPC: &http.Client{Transport: logpolicy.NewLogtailTransport(logtail.DefaultHost)}, 321 | } 322 | drainCh := make(chan struct{}) 323 | logcfg.DrainLogs = drainCh 324 | go func() { 325 | // Upload logs infrequently. Interval chosen arbitrarily. 326 | // The objective is to reduce phone power use. 327 | t := time.NewTicker(2 * time.Minute) 328 | for range t.C { 329 | select { 330 | case drainCh <- struct{}{}: 331 | default: 332 | } 333 | } 334 | }() 335 | 336 | filchOpts := filch.Options{ 337 | ReplaceStderr: true, 338 | } 339 | 340 | var filchErr error 341 | if logDir != "" { 342 | logPath := filepath.Join(logDir, "ipn.log.") 343 | logcfg.Buffer, filchErr = filch.New(logPath, filchOpts) 344 | } 345 | 346 | logf := logger.RusagePrefixLog(log.Printf) 347 | tlog := logtail.NewLogger(logcfg, logf) 348 | 349 | log.SetFlags(0) 350 | log.SetOutput(tlog) 351 | 352 | log.Printf("goSetupLogs: success") 353 | 354 | if logDir == "" { 355 | log.Printf("SetupLogs: no logDir, storing logs in memory") 356 | } 357 | if filchErr != nil { 358 | log.Printf("SetupLogs: filch setup failed: %v", filchErr) 359 | } 360 | } 361 | 362 | // We log the result of each of the DNS configuration discovery mechanisms, as we're 363 | // expecting a long tail of obscure Android devices with interesting behavior. 364 | func (b *backend) logDNSConfigMechanisms() { 365 | err := jni.Do(b.jvm, func(env *jni.Env) error { 366 | cls := jni.GetObjectClass(env, b.appCtx) 367 | m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;") 368 | dns, err := jni.CallObjectMethod(env, b.appCtx, m) 369 | if err != nil { 370 | return fmt.Errorf("getDnsConfigObj JNI: %v", err) 371 | } 372 | dnsCls := jni.GetObjectClass(env, dns) 373 | 374 | for _, impl := range []string{"getDnsConfigFromLinkProperties", 375 | "getDnsServersFromSystemProperties", 376 | "getDnsServersFromNetworkInfo"} { 377 | 378 | m = jni.GetMethodID(env, dnsCls, impl, "()Ljava/lang/String;") 379 | n, err := jni.CallObjectMethod(env, dns, m) 380 | baseConfig := jni.GoString(env, jni.String(n)) 381 | if err != nil { 382 | log.Printf("%s JNI: %v", impl, err) 383 | } else { 384 | oneLine := strings.Replace(baseConfig, "\n", ";", -1) 385 | log.Printf("%s: %s", impl, oneLine) 386 | } 387 | } 388 | return nil 389 | }) 390 | if err != nil { 391 | log.Printf("logDNSConfigMechanisms: %v", err) 392 | } 393 | } 394 | 395 | func (b *backend) getPlatformDNSConfig() string { 396 | var baseConfig string 397 | err := jni.Do(b.jvm, func(env *jni.Env) error { 398 | cls := jni.GetObjectClass(env, b.appCtx) 399 | m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;") 400 | dns, err := jni.CallObjectMethod(env, b.appCtx, m) 401 | if err != nil { 402 | return fmt.Errorf("getDnsConfigObj: %v", err) 403 | } 404 | dnsCls := jni.GetObjectClass(env, dns) 405 | m = jni.GetMethodID(env, dnsCls, "getDnsConfigAsString", "()Ljava/lang/String;") 406 | n, err := jni.CallObjectMethod(env, dns, m) 407 | baseConfig = jni.GoString(env, jni.String(n)) 408 | return err 409 | }) 410 | if err != nil { 411 | log.Printf("getPlatformDNSConfig JNI: %v", err) 412 | return "" 413 | } 414 | return baseConfig 415 | } 416 | 417 | func (b *backend) getDNSBaseConfig() (dns.OSConfig, error) { 418 | b.logDNSConfigMechanisms() 419 | baseConfig := b.getPlatformDNSConfig() 420 | lines := strings.Split(baseConfig, "\n") 421 | if len(lines) == 0 { 422 | return dns.OSConfig{}, nil 423 | } 424 | 425 | config := dns.OSConfig{} 426 | addrs := strings.Trim(lines[0], " \n") 427 | for _, addr := range strings.Split(addrs, " ") { 428 | ip, err := netaddr.ParseIP(addr) 429 | if err == nil { 430 | config.Nameservers = append(config.Nameservers, ip) 431 | } 432 | } 433 | 434 | if len(lines) > 1 { 435 | for _, s := range strings.Split(strings.Trim(lines[1], " \n"), " ") { 436 | domain, err := dnsname.ToFQDN(s) 437 | if err != nil { 438 | log.Printf("getDNSBaseConfig: unable to parse %q: %v", s, err) 439 | continue 440 | } 441 | config.SearchDomains = append(config.SearchDomains, domain) 442 | } 443 | } 444 | 445 | return config, nil 446 | } 447 | -------------------------------------------------------------------------------- /cmd/tailscale/callbacks.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | // JNI implementations of Java native callback methods. 8 | 9 | import ( 10 | "unsafe" 11 | 12 | "github.com/tailscale/tailscale-android/jni" 13 | ) 14 | 15 | // #include 16 | import "C" 17 | 18 | var ( 19 | // onVPNPrepared is notified when VpnService.prepare succeeds. 20 | onVPNPrepared = make(chan struct{}, 1) 21 | // onVPNClosed is notified when VpnService.prepare fails, or when 22 | // the a running VPN connection is closed. 23 | onVPNClosed = make(chan struct{}, 1) 24 | // onVPNRevoked is notified whenever the VPN service is revoked. 25 | onVPNRevoked = make(chan struct{}, 1) 26 | 27 | // onConnect receives global IPNService references when 28 | // a VPN connection is requested. 29 | onConnect = make(chan jni.Object) 30 | // onDisconnect receives global IPNService references when 31 | // disconnecting. 32 | onDisconnect = make(chan jni.Object) 33 | // onConnectivityChange is notified every time the network 34 | // conditions change. 35 | onConnectivityChange = make(chan bool, 1) 36 | 37 | // onGoogleToken receives google ID tokens. 38 | onGoogleToken = make(chan string) 39 | 40 | // onFileShare receives file sharing intents. 41 | onFileShare = make(chan []File, 1) 42 | 43 | // onWriteStorageGranted is notified when we are granted WRITE_STORAGE_PERMISSION. 44 | onWriteStorageGranted = make(chan struct{}, 1) 45 | ) 46 | 47 | const ( 48 | // Request codes for Android callbacks. 49 | // requestSignin is for Google Sign-In. 50 | requestSignin C.jint = 1000 + iota 51 | // requestPrepareVPN is for when Android's VpnService.prepare 52 | // completes. 53 | requestPrepareVPN 54 | ) 55 | 56 | // resultOK is Android's Activity.RESULT_OK. 57 | const resultOK = -1 58 | 59 | //export Java_com_tailscale_ipn_App_onVPNPrepared 60 | func Java_com_tailscale_ipn_App_onVPNPrepared(env *C.JNIEnv, class C.jclass) { 61 | notifyVPNPrepared() 62 | } 63 | 64 | //export Java_com_tailscale_ipn_App_onWriteStorageGranted 65 | func Java_com_tailscale_ipn_App_onWriteStorageGranted(env *C.JNIEnv, class C.jclass) { 66 | select { 67 | case onWriteStorageGranted <- struct{}{}: 68 | default: 69 | } 70 | } 71 | 72 | func notifyVPNPrepared() { 73 | select { 74 | case onVPNPrepared <- struct{}{}: 75 | default: 76 | } 77 | } 78 | 79 | func notifyVPNRevoked() { 80 | select { 81 | case onVPNRevoked <- struct{}{}: 82 | default: 83 | } 84 | } 85 | 86 | func notifyVPNClosed() { 87 | select { 88 | case onVPNClosed <- struct{}{}: 89 | default: 90 | } 91 | } 92 | 93 | //export Java_com_tailscale_ipn_IPNService_connect 94 | func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) { 95 | jenv := (*jni.Env)(unsafe.Pointer(env)) 96 | onConnect <- jni.NewGlobalRef(jenv, jni.Object(this)) 97 | } 98 | 99 | //export Java_com_tailscale_ipn_IPNService_disconnect 100 | func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) { 101 | jenv := (*jni.Env)(unsafe.Pointer(env)) 102 | onDisconnect <- jni.NewGlobalRef(jenv, jni.Object(this)) 103 | } 104 | 105 | //export Java_com_tailscale_ipn_App_onConnectivityChanged 106 | func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls C.jclass, connected C.jboolean) { 107 | select { 108 | case <-onConnectivityChange: 109 | default: 110 | } 111 | onConnectivityChange <- connected == C.JNI_TRUE 112 | } 113 | 114 | //export Java_com_tailscale_ipn_QuickToggleService_onTileClick 115 | func Java_com_tailscale_ipn_QuickToggleService_onTileClick(env *C.JNIEnv, cls C.jclass) { 116 | requestBackend(ToggleEvent{}) 117 | } 118 | 119 | //export Java_com_tailscale_ipn_Peer_onActivityResult0 120 | func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C.jclass, act C.jobject, reqCode, resCode C.jint) { 121 | switch reqCode { 122 | case requestSignin: 123 | if resCode != resultOK { 124 | onGoogleToken <- "" 125 | break 126 | } 127 | jenv := (*jni.Env)(unsafe.Pointer(env)) 128 | m := jni.GetStaticMethodID(jenv, googleClass, 129 | "getIdTokenForActivity", "(Landroid/app/Activity;)Ljava/lang/String;") 130 | idToken, err := jni.CallStaticObjectMethod(jenv, googleClass, m, jni.Value(act)) 131 | if err != nil { 132 | fatalErr(err) 133 | break 134 | } 135 | tok := jni.GoString(jenv, jni.String(idToken)) 136 | onGoogleToken <- tok 137 | case requestPrepareVPN: 138 | if resCode == resultOK { 139 | notifyVPNPrepared() 140 | } else { 141 | notifyVPNClosed() 142 | notifyVPNRevoked() 143 | } 144 | } 145 | } 146 | 147 | //export Java_com_tailscale_ipn_App_onShareIntent 148 | func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclass, nfiles C.jint, jtypes C.jintArray, jmimes C.jobjectArray, jitems C.jobjectArray, jnames C.jobjectArray, jsizes C.jlongArray) { 149 | const ( 150 | typeNone = 0 151 | typeInline = 1 152 | typeURI = 2 153 | ) 154 | jenv := (*jni.Env)(unsafe.Pointer(env)) 155 | types := jni.GetIntArrayElements(jenv, jni.IntArray(jtypes)) 156 | mimes := jni.GetStringArrayElements(jenv, jni.ObjectArray(jmimes)) 157 | items := jni.GetStringArrayElements(jenv, jni.ObjectArray(jitems)) 158 | names := jni.GetStringArrayElements(jenv, jni.ObjectArray(jnames)) 159 | sizes := jni.GetLongArrayElements(jenv, jni.LongArray(jsizes)) 160 | var files []File 161 | for i := 0; i < int(nfiles); i++ { 162 | f := File{ 163 | Type: FileType(types[i]), 164 | MIMEType: mimes[i], 165 | Name: names[i], 166 | } 167 | if f.Name == "" { 168 | f.Name = "file.bin" 169 | } 170 | switch f.Type { 171 | case FileTypeText: 172 | f.Text = items[i] 173 | f.Size = int64(len(f.Text)) 174 | case FileTypeURI: 175 | f.URI = items[i] 176 | f.Size = sizes[i] 177 | default: 178 | panic("unknown file type") 179 | } 180 | files = append(files, f) 181 | } 182 | select { 183 | case <-onFileShare: 184 | default: 185 | } 186 | onFileShare <- files 187 | } 188 | -------------------------------------------------------------------------------- /cmd/tailscale/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/cmd/tailscale/google.png -------------------------------------------------------------------------------- /cmd/tailscale/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "crypto/rand" 10 | "crypto/sha1" 11 | "encoding/hex" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "log" 16 | "mime" 17 | "net" 18 | "net/http" 19 | "net/url" 20 | "os" 21 | "path/filepath" 22 | "sort" 23 | "strings" 24 | "sync/atomic" 25 | "time" 26 | "unsafe" 27 | 28 | "gioui.org/app" 29 | "gioui.org/io/system" 30 | "gioui.org/layout" 31 | "gioui.org/op" 32 | "inet.af/netaddr" 33 | 34 | "github.com/tailscale/tailscale-android/jni" 35 | "tailscale.com/client/tailscale/apitype" 36 | "tailscale.com/hostinfo" 37 | "tailscale.com/ipn" 38 | "tailscale.com/ipn/ipnlocal" 39 | "tailscale.com/net/dns" 40 | "tailscale.com/net/interfaces" 41 | "tailscale.com/net/netns" 42 | "tailscale.com/paths" 43 | "tailscale.com/tailcfg" 44 | "tailscale.com/types/netmap" 45 | "tailscale.com/wgengine/router" 46 | ) 47 | 48 | type App struct { 49 | jvm *jni.JVM 50 | // appCtx is a global reference to the com.tailscale.ipn.App instance. 51 | appCtx jni.Object 52 | 53 | store *stateStore 54 | logIDPublicAtomic atomic.Value // of string 55 | 56 | // netStates receives the most recent network state. 57 | netStates chan BackendState 58 | // prefs receives new preferences from the backend. 59 | prefs chan *ipn.Prefs 60 | // browseURLs receives URLs when the backend wants to browse. 61 | browseURLs chan string 62 | // targetsLoaded receives lists of file targets. 63 | targetsLoaded chan FileTargets 64 | // invalidates receives whenever the window should be refreshed. 65 | invalidates chan struct{} 66 | } 67 | 68 | var ( 69 | // googleClass is a global reference to the com.tailscale.ipn.Google class. 70 | googleClass jni.Class 71 | ) 72 | 73 | type FileTargets struct { 74 | Targets []*apitype.FileTarget 75 | Err error 76 | } 77 | 78 | type File struct { 79 | Type FileType 80 | Name string 81 | Size int64 82 | MIMEType string 83 | // URI of the file, valid if Type is FileTypeURI. 84 | URI string 85 | // Text is the content of the file, if Type is FileTypeText. 86 | Text string 87 | } 88 | 89 | // FileSendInfo describes the state of an ongoing file send operation. 90 | type FileSendInfo struct { 91 | State FileSendState 92 | // Progress tracks the progress of the transfer from 0.0 to 1.0. Valid 93 | // only when State is FileSendStarted. 94 | Progress float64 95 | } 96 | 97 | type clientState struct { 98 | browseURL string 99 | backend BackendState 100 | // query is the search query, in lowercase. 101 | query string 102 | 103 | Peers []UIPeer 104 | } 105 | 106 | type FileType uint8 107 | 108 | // FileType constants are known to IPNActivity.java. 109 | const ( 110 | FileTypeText FileType = 1 111 | FileTypeURI FileType = 2 112 | ) 113 | 114 | type ExitStatus uint8 115 | 116 | const ( 117 | // No exit node selected. 118 | ExitNone ExitStatus = iota 119 | // Exit node selected and exists, but is offline or missing. 120 | ExitOffline 121 | // Exit node selected and online. 122 | ExitOnline 123 | ) 124 | 125 | type FileSendState uint8 126 | 127 | const ( 128 | FileSendNotStarted FileSendState = iota 129 | FileSendConnecting 130 | FileSendTransferring 131 | FileSendComplete 132 | FileSendFailed 133 | ) 134 | 135 | type Peer struct { 136 | Label string 137 | Online bool 138 | ID tailcfg.StableNodeID 139 | } 140 | 141 | type BackendState struct { 142 | Prefs *ipn.Prefs 143 | State ipn.State 144 | NetworkMap *netmap.NetworkMap 145 | LostInternet bool 146 | // Exits are the peers that can act as exit node. 147 | Exits []Peer 148 | // ExitState describes the state of our exit node. 149 | ExitStatus ExitStatus 150 | // Exit is our current exit node, if any. 151 | Exit Peer 152 | } 153 | 154 | // UIEvent is an event flowing from the UI to the backend. 155 | type UIEvent interface{} 156 | 157 | type RouteAllEvent struct { 158 | ID tailcfg.StableNodeID 159 | } 160 | 161 | type ConnectEvent struct { 162 | Enable bool 163 | } 164 | 165 | type CopyEvent struct { 166 | Text string 167 | } 168 | 169 | type SearchEvent struct { 170 | Query string 171 | } 172 | 173 | type OAuth2Event struct { 174 | Token *tailcfg.Oauth2Token 175 | } 176 | 177 | type FileSendEvent struct { 178 | Target *apitype.FileTarget 179 | Context context.Context 180 | Updates func(FileSendInfo) 181 | } 182 | 183 | type SetLoginServerEvent struct { 184 | URL string 185 | } 186 | 187 | // UIEvent types. 188 | type ( 189 | ToggleEvent struct{} 190 | ReauthEvent struct{} 191 | BugEvent struct{} 192 | WebAuthEvent struct{} 193 | GoogleAuthEvent struct{} 194 | LogoutEvent struct{} 195 | BeExitNodeEvent bool 196 | ExitAllowLANEvent bool 197 | ) 198 | 199 | // serverOAuthID is the OAuth ID of the tailscale-android server, used 200 | // by GoogleSignInOptions.Builder.requestIdToken. 201 | const serverOAuthID = "744055068597-hv4opg0h7vskq1hv37nq3u26t8c15qk0.apps.googleusercontent.com" 202 | 203 | // releaseCertFingerprint is the SHA-1 fingerprint of the Google Play Store signing key. 204 | // It is used to check whether the app is signed for release. 205 | const releaseCertFingerprint = "86:9D:11:8B:63:1E:F8:35:C6:D9:C2:66:53:BC:28:22:2F:B8:C1:AE" 206 | 207 | // backendEvents receives events from the UI (Activity, Tile etc.) to the backend. 208 | var backendEvents = make(chan UIEvent) 209 | 210 | func main() { 211 | a := &App{ 212 | jvm: (*jni.JVM)(unsafe.Pointer(app.JavaVM())), 213 | appCtx: jni.Object(app.AppContext()), 214 | netStates: make(chan BackendState, 1), 215 | browseURLs: make(chan string, 1), 216 | prefs: make(chan *ipn.Prefs, 1), 217 | targetsLoaded: make(chan FileTargets, 1), 218 | invalidates: make(chan struct{}, 1), 219 | } 220 | err := jni.Do(a.jvm, func(env *jni.Env) error { 221 | loader := jni.ClassLoaderFor(env, a.appCtx) 222 | cl, err := jni.LoadClass(env, loader, "com.tailscale.ipn.Google") 223 | if err != nil { 224 | // Ignore load errors; the Google class is not included in F-Droid builds. 225 | return nil 226 | } 227 | googleClass = jni.Class(jni.NewGlobalRef(env, jni.Object(cl))) 228 | return nil 229 | }) 230 | if err != nil { 231 | fatalErr(err) 232 | } 233 | a.store = newStateStore(a.jvm, a.appCtx) 234 | interfaces.RegisterInterfaceGetter(a.getInterfaces) 235 | go func() { 236 | if err := a.runBackend(); err != nil { 237 | fatalErr(err) 238 | } 239 | }() 240 | go func() { 241 | if err := a.runUI(); err != nil { 242 | fatalErr(err) 243 | } 244 | }() 245 | app.Main() 246 | } 247 | 248 | func (a *App) runBackend() error { 249 | appDir, err := app.DataDir() 250 | if err != nil { 251 | fatalErr(err) 252 | } 253 | paths.AppSharedDir.Store(appDir) 254 | hostinfo.SetOSVersion(a.osVersion()) 255 | if !googleSignInEnabled() { 256 | hostinfo.SetPackage("nogoogle") 257 | } 258 | deviceModel := a.modelName() 259 | if a.isChromeOS() { 260 | deviceModel = "ChromeOS: " + deviceModel 261 | } 262 | hostinfo.SetDeviceModel(deviceModel) 263 | 264 | type configPair struct { 265 | rcfg *router.Config 266 | dcfg *dns.OSConfig 267 | } 268 | configs := make(chan configPair) 269 | configErrs := make(chan error) 270 | b, err := newBackend(appDir, a.jvm, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { 271 | if rcfg == nil { 272 | return nil 273 | } 274 | configs <- configPair{rcfg, dcfg} 275 | return <-configErrs 276 | }) 277 | if err != nil { 278 | return err 279 | } 280 | a.logIDPublicAtomic.Store(b.logIDPublic) 281 | defer b.CloseTUNs() 282 | 283 | // Contrary to the documentation for VpnService.Builder.addDnsServer, 284 | // ChromeOS doesn't fall back to the underlying network nameservers if 285 | // we don't provide any. 286 | b.avoidEmptyDNS = a.isChromeOS() 287 | 288 | var timer *time.Timer 289 | var alarmChan <-chan time.Time 290 | alarm := func(t *time.Timer) { 291 | if timer != nil { 292 | timer.Stop() 293 | } 294 | timer = t 295 | if timer != nil { 296 | alarmChan = timer.C 297 | } 298 | } 299 | notifications := make(chan ipn.Notify, 1) 300 | startErr := make(chan error) 301 | // Start from a goroutine to avoid deadlock when Start 302 | // calls the callback. 303 | go func() { 304 | startErr <- b.Start(func(n ipn.Notify) { 305 | notifications <- n 306 | }) 307 | }() 308 | var ( 309 | cfg configPair 310 | state BackendState 311 | service jni.Object // of IPNService 312 | signingIn bool 313 | ) 314 | var ( 315 | waitingFilesDone = make(chan struct{}) 316 | waitingFiles bool 317 | processingFiles bool 318 | ) 319 | processFiles := func() { 320 | if !waitingFiles || processingFiles { 321 | return 322 | } 323 | processingFiles = true 324 | waitingFiles = false 325 | go func() { 326 | if err := a.processWaitingFiles(b.backend); err != nil { 327 | log.Printf("processWaitingFiles: %v", err) 328 | } 329 | waitingFilesDone <- struct{}{} 330 | }() 331 | } 332 | for { 333 | select { 334 | case err := <-startErr: 335 | if err != nil { 336 | return err 337 | } 338 | case <-waitingFilesDone: 339 | processingFiles = false 340 | processFiles() 341 | case s := <-configs: 342 | cfg = s 343 | if b == nil || service == 0 || cfg.rcfg == nil { 344 | configErrs <- nil 345 | break 346 | } 347 | configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg) 348 | case n := <-notifications: 349 | exitWasOnline := state.ExitStatus == ExitOnline 350 | if p := n.Prefs; p != nil { 351 | first := state.Prefs == nil 352 | state.Prefs = p.Clone() 353 | state.updateExitNodes() 354 | if first { 355 | state.Prefs.Hostname = a.hostname() 356 | go b.backend.SetPrefs(state.Prefs) 357 | } 358 | a.setPrefs(state.Prefs) 359 | } 360 | if s := n.State; s != nil { 361 | oldState := state.State 362 | state.State = *s 363 | if service != 0 { 364 | a.updateNotification(service, state.State) 365 | } 366 | if service != 0 { 367 | if cfg.rcfg != nil && state.State >= ipn.Starting { 368 | if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { 369 | log.Printf("VPN update failed: %v", err) 370 | notifyVPNClosed() 371 | } 372 | } else { 373 | b.CloseTUNs() 374 | } 375 | } 376 | // Stop VPN if we logged out. 377 | if oldState > ipn.Stopped && state.State <= ipn.Stopped { 378 | if err := a.callVoidMethod(a.appCtx, "stopVPN", "()V"); err != nil { 379 | fatalErr(err) 380 | } 381 | } 382 | a.notify(state) 383 | } 384 | if u := n.BrowseToURL; u != nil { 385 | signingIn = false 386 | a.setURL(*u) 387 | } 388 | if m := n.NetMap; m != nil { 389 | state.NetworkMap = m 390 | state.updateExitNodes() 391 | a.notify(state) 392 | if service != 0 { 393 | alarm(a.notifyExpiry(service, m.Expiry)) 394 | } 395 | } 396 | // Notify if a previously online exit is not longer online (or missing). 397 | if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline { 398 | a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.") 399 | } 400 | targets, err := b.backend.FileTargets() 401 | if err != nil { 402 | // Construct a user-visible error message. 403 | if b.backend.State() != ipn.Running { 404 | err = fmt.Errorf("Not connected to tailscale") 405 | } else { 406 | err = fmt.Errorf("Failed to load device list") 407 | } 408 | } 409 | a.targetsLoaded <- FileTargets{targets, err} 410 | waitingFiles = n.FilesWaiting != nil 411 | processFiles() 412 | case <-onWriteStorageGranted: 413 | processFiles() 414 | case <-alarmChan: 415 | if m := state.NetworkMap; m != nil && service != 0 { 416 | alarm(a.notifyExpiry(service, m.Expiry)) 417 | } 418 | case e := <-backendEvents: 419 | switch e := e.(type) { 420 | case OAuth2Event: 421 | go b.backend.Login(e.Token) 422 | case ToggleEvent: 423 | state.Prefs.WantRunning = !state.Prefs.WantRunning 424 | go b.backend.SetPrefs(state.Prefs) 425 | case BeExitNodeEvent: 426 | state.Prefs.SetAdvertiseExitNode(bool(e)) 427 | go b.backend.SetPrefs(state.Prefs) 428 | case ExitAllowLANEvent: 429 | state.Prefs.ExitNodeAllowLANAccess = bool(e) 430 | go b.backend.SetPrefs(state.Prefs) 431 | case WebAuthEvent: 432 | if !signingIn { 433 | go b.backend.StartLoginInteractive() 434 | signingIn = true 435 | } 436 | case SetLoginServerEvent: 437 | state.Prefs.ControlURL = e.URL 438 | b.backend.SetPrefs(state.Prefs) 439 | // A hack to get around ipnlocal's inability to update 440 | // ControlURL after Start()... Can we re-init instead? 441 | os.Exit(0) 442 | case LogoutEvent: 443 | go b.backend.Logout() 444 | case ConnectEvent: 445 | state.Prefs.WantRunning = e.Enable 446 | go b.backend.SetPrefs(state.Prefs) 447 | case RouteAllEvent: 448 | state.Prefs.ExitNodeID = e.ID 449 | go b.backend.SetPrefs(state.Prefs) 450 | state.updateExitNodes() 451 | a.notify(state) 452 | } 453 | case s := <-onConnect: 454 | jni.Do(a.jvm, func(env *jni.Env) error { 455 | if jni.IsSameObject(env, s, service) { 456 | // We already have a reference. 457 | jni.DeleteGlobalRef(env, s) 458 | return nil 459 | } 460 | if service != 0 { 461 | jni.DeleteGlobalRef(env, service) 462 | } 463 | netns.SetAndroidProtectFunc(func(fd int) error { 464 | return jni.Do(a.jvm, func(env *jni.Env) error { 465 | // Call https://developer.android.com/reference/android/net/VpnService#protect(int) 466 | // to mark fd as a socket that should bypass the VPN and use the underlying network. 467 | cls := jni.GetObjectClass(env, s) 468 | m := jni.GetMethodID(env, cls, "protect", "(I)Z") 469 | ok, err := jni.CallBooleanMethod(env, s, m, jni.Value(fd)) 470 | // TODO(bradfitz): return an error back up to netns if this fails, once 471 | // we've had some experience with this and analyzed the logs over a wide 472 | // range of Android phones. For now we're being paranoid and conservative 473 | // and do the JNI call to protect best effort, only logging if it fails. 474 | // The risk of returning an error is that it breaks users on some Android 475 | // versions even when they're not using exit nodes. I'd rather the 476 | // relatively few number of exit node users file bug reports if Tailscale 477 | // doesn't work and then we can look for this log print. 478 | if err != nil || !ok { 479 | log.Printf("[unexpected] VpnService.protect(%d) = %v, %v", fd, ok, err) 480 | } 481 | return nil // even on error. see big TODO above. 482 | }) 483 | }) 484 | service = s 485 | return nil 486 | }) 487 | a.updateNotification(service, state.State) 488 | if m := state.NetworkMap; m != nil { 489 | alarm(a.notifyExpiry(service, m.Expiry)) 490 | } 491 | if cfg.rcfg != nil && state.State >= ipn.Starting { 492 | if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil { 493 | log.Printf("VPN update failed: %v", err) 494 | notifyVPNClosed() 495 | } 496 | } 497 | case connected := <-onConnectivityChange: 498 | if state.LostInternet != !connected { 499 | log.Printf("LostInternet state change: %v -> %v", state.LostInternet, !connected) 500 | } 501 | state.LostInternet = !connected 502 | if b != nil { 503 | go b.LinkChange() 504 | } 505 | a.notify(state) 506 | case s := <-onDisconnect: 507 | b.CloseTUNs() 508 | jni.Do(a.jvm, func(env *jni.Env) error { 509 | defer jni.DeleteGlobalRef(env, s) 510 | if jni.IsSameObject(env, service, s) { 511 | netns.SetAndroidProtectFunc(nil) 512 | jni.DeleteGlobalRef(env, service) 513 | service = 0 514 | } 515 | return nil 516 | }) 517 | if state.State >= ipn.Starting { 518 | notifyVPNClosed() 519 | } 520 | } 521 | } 522 | } 523 | 524 | func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error { 525 | files, err := b.WaitingFiles() 526 | if err != nil { 527 | return err 528 | } 529 | var aerr error 530 | for _, f := range files { 531 | if err := a.downloadFile(b, f); err != nil && aerr == nil { 532 | aerr = err 533 | } 534 | } 535 | return aerr 536 | } 537 | 538 | func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.WaitingFile) (cerr error) { 539 | in, _, err := b.OpenFile(f.Name) 540 | if err != nil { 541 | return err 542 | } 543 | defer in.Close() 544 | ext := filepath.Ext(f.Name) 545 | mimeType := mime.TypeByExtension(ext) 546 | var mediaURI string 547 | err = jni.Do(a.jvm, func(env *jni.Env) error { 548 | cls := jni.GetObjectClass(env, a.appCtx) 549 | insertMedia := jni.GetMethodID(env, cls, "insertMedia", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;") 550 | jname := jni.JavaString(env, f.Name) 551 | jmime := jni.JavaString(env, mimeType) 552 | uri, err := jni.CallObjectMethod(env, a.appCtx, insertMedia, jni.Value(jname), jni.Value(jmime)) 553 | if err != nil { 554 | return err 555 | } 556 | mediaURI = jni.GoString(env, jni.String(uri)) 557 | return nil 558 | }) 559 | if err != nil { 560 | return fmt.Errorf("insertMedia: %w", err) 561 | } 562 | deleteURI := func(uri string) error { 563 | return jni.Do(a.jvm, func(env *jni.Env) error { 564 | cls := jni.GetObjectClass(env, a.appCtx) 565 | m := jni.GetMethodID(env, cls, "deleteUri", "(Ljava/lang/String;)V") 566 | juri := jni.JavaString(env, uri) 567 | return jni.CallVoidMethod(env, a.appCtx, m, jni.Value(juri)) 568 | }) 569 | } 570 | out, err := a.openURI(mediaURI, "w") 571 | if err != nil { 572 | deleteURI(mediaURI) 573 | return fmt.Errorf("openUri: %w", err) 574 | } 575 | if _, err := io.Copy(out, in); err != nil { 576 | deleteURI(mediaURI) 577 | return fmt.Errorf("copy: %w", err) 578 | } 579 | if err := out.Close(); err != nil { 580 | deleteURI(mediaURI) 581 | return fmt.Errorf("close: %w", err) 582 | } 583 | if err := a.notifyFile(mediaURI, f.Name); err != nil { 584 | fatalErr(err) 585 | } 586 | return b.DeleteFile(f.Name) 587 | } 588 | 589 | // openURI calls a.appCtx.getContentResolver().openFileDescriptor on uri and 590 | // mode and returns the detached file descriptor. 591 | func (a *App) openURI(uri, mode string) (*os.File, error) { 592 | var f *os.File 593 | err := jni.Do(a.jvm, func(env *jni.Env) error { 594 | cls := jni.GetObjectClass(env, a.appCtx) 595 | openURI := jni.GetMethodID(env, cls, "openUri", "(Ljava/lang/String;Ljava/lang/String;)I") 596 | juri := jni.JavaString(env, uri) 597 | jmode := jni.JavaString(env, mode) 598 | fd, err := jni.CallIntMethod(env, a.appCtx, openURI, jni.Value(juri), jni.Value(jmode)) 599 | if err != nil { 600 | return err 601 | } 602 | f = os.NewFile(uintptr(fd), "media-store") 603 | return nil 604 | }) 605 | return f, err 606 | } 607 | 608 | func (a *App) isChromeOS() bool { 609 | var chromeOS bool 610 | err := jni.Do(a.jvm, func(env *jni.Env) error { 611 | cls := jni.GetObjectClass(env, a.appCtx) 612 | m := jni.GetMethodID(env, cls, "isChromeOS", "()Z") 613 | b, err := jni.CallBooleanMethod(env, a.appCtx, m) 614 | chromeOS = b 615 | return err 616 | }) 617 | if err != nil { 618 | panic(err) 619 | } 620 | return chromeOS 621 | } 622 | 623 | func (s *BackendState) updateExitNodes() { 624 | s.ExitStatus = ExitNone 625 | var exitID tailcfg.StableNodeID 626 | if p := s.Prefs; p != nil { 627 | exitID = p.ExitNodeID 628 | if exitID != "" { 629 | s.ExitStatus = ExitOffline 630 | } 631 | } 632 | hasMyExit := exitID == "" 633 | s.Exits = nil 634 | var peers []*tailcfg.Node 635 | if s.NetworkMap != nil { 636 | peers = s.NetworkMap.Peers 637 | } 638 | for _, p := range peers { 639 | canRoute := false 640 | for _, r := range p.AllowedIPs { 641 | if r == netaddr.MustParseIPPrefix("0.0.0.0/0") || r == netaddr.MustParseIPPrefix("::/0") { 642 | canRoute = true 643 | break 644 | } 645 | } 646 | myExit := p.StableID == exitID 647 | hasMyExit = hasMyExit || myExit 648 | exit := Peer{ 649 | Label: p.DisplayName(true), 650 | Online: canRoute, 651 | ID: p.StableID, 652 | } 653 | if myExit { 654 | s.Exit = exit 655 | if canRoute { 656 | s.ExitStatus = ExitOnline 657 | } 658 | } 659 | if canRoute || myExit { 660 | s.Exits = append(s.Exits, exit) 661 | } 662 | } 663 | sort.Slice(s.Exits, func(i, j int) bool { 664 | return s.Exits[i].Label < s.Exits[j].Label 665 | }) 666 | if !hasMyExit { 667 | // Insert node missing from netmap. 668 | s.Exit = Peer{Label: "Unknown device", ID: exitID} 669 | s.Exits = append([]Peer{s.Exit}, s.Exits...) 670 | } 671 | } 672 | 673 | // hostname builds a hostname from android.os.Build fields, in place of a 674 | // useless os.Hostname(). 675 | func (a *App) hostname() string { 676 | var hostname string 677 | err := jni.Do(a.jvm, func(env *jni.Env) error { 678 | cls := jni.GetObjectClass(env, a.appCtx) 679 | getHostname := jni.GetMethodID(env, cls, "getHostname", "()Ljava/lang/String;") 680 | n, err := jni.CallObjectMethod(env, a.appCtx, getHostname) 681 | hostname = jni.GoString(env, jni.String(n)) 682 | return err 683 | }) 684 | if err != nil { 685 | panic(err) 686 | } 687 | return hostname 688 | } 689 | 690 | // osVersion returns android.os.Build.VERSION.RELEASE. " [nogoogle]" is appended 691 | // if Google Play services are not compiled in. 692 | func (a *App) osVersion() string { 693 | var version string 694 | err := jni.Do(a.jvm, func(env *jni.Env) error { 695 | cls := jni.GetObjectClass(env, a.appCtx) 696 | m := jni.GetMethodID(env, cls, "getOSVersion", "()Ljava/lang/String;") 697 | n, err := jni.CallObjectMethod(env, a.appCtx, m) 698 | version = jni.GoString(env, jni.String(n)) 699 | return err 700 | }) 701 | if err != nil { 702 | panic(err) 703 | } 704 | return version 705 | } 706 | 707 | // modelName return the MANUFACTURER + MODEL from 708 | // android.os.Build. 709 | func (a *App) modelName() string { 710 | var model string 711 | err := jni.Do(a.jvm, func(env *jni.Env) error { 712 | cls := jni.GetObjectClass(env, a.appCtx) 713 | m := jni.GetMethodID(env, cls, "getModelName", "()Ljava/lang/String;") 714 | n, err := jni.CallObjectMethod(env, a.appCtx, m) 715 | model = jni.GoString(env, jni.String(n)) 716 | return err 717 | }) 718 | if err != nil { 719 | panic(err) 720 | } 721 | return model 722 | } 723 | 724 | func googleSignInEnabled() bool { 725 | return googleClass != 0 726 | } 727 | 728 | // updateNotification updates the foreground persistent status notification. 729 | func (a *App) updateNotification(service jni.Object, state ipn.State) error { 730 | var msg, title string 731 | switch state { 732 | case ipn.Starting: 733 | title, msg = "Connecting...", "" 734 | case ipn.Running: 735 | title, msg = "Connected", "" 736 | default: 737 | return nil 738 | } 739 | return jni.Do(a.jvm, func(env *jni.Env) error { 740 | cls := jni.GetObjectClass(env, service) 741 | update := jni.GetMethodID(env, cls, "updateStatusNotification", "(Ljava/lang/String;Ljava/lang/String;)V") 742 | jtitle := jni.JavaString(env, title) 743 | jmessage := jni.JavaString(env, msg) 744 | return jni.CallVoidMethod(env, service, update, jni.Value(jtitle), jni.Value(jmessage)) 745 | }) 746 | } 747 | 748 | // notifyExpiry notifies the user of imminent session expiry and 749 | // returns a new timer that triggers when the user should be notified 750 | // again. 751 | func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer { 752 | if expiry.IsZero() { 753 | return nil 754 | } 755 | d := time.Until(expiry) 756 | var title string 757 | const msg = "Reauthenticate to maintain the connection to your network." 758 | var t *time.Timer 759 | const ( 760 | aday = 24 * time.Hour 761 | soon = 5 * time.Minute 762 | ) 763 | switch { 764 | case d <= 0: 765 | title = "Your authentication has expired!" 766 | case d <= soon: 767 | title = "Your authentication expires soon!" 768 | t = time.NewTimer(d) 769 | case d <= aday: 770 | title = "Your authentication expires in a day." 771 | t = time.NewTimer(d - soon) 772 | default: 773 | return time.NewTimer(d - aday) 774 | } 775 | if err := a.pushNotify(service, title, msg); err != nil { 776 | fatalErr(err) 777 | } 778 | return t 779 | } 780 | 781 | func (a *App) notifyFile(uri, msg string) error { 782 | return jni.Do(a.jvm, func(env *jni.Env) error { 783 | cls := jni.GetObjectClass(env, a.appCtx) 784 | notify := jni.GetMethodID(env, cls, "notifyFile", "(Ljava/lang/String;Ljava/lang/String;)V") 785 | juri := jni.JavaString(env, uri) 786 | jmsg := jni.JavaString(env, msg) 787 | return jni.CallVoidMethod(env, a.appCtx, notify, jni.Value(juri), jni.Value(jmsg)) 788 | }) 789 | } 790 | 791 | func (a *App) pushNotify(service jni.Object, title, msg string) error { 792 | return jni.Do(a.jvm, func(env *jni.Env) error { 793 | cls := jni.GetObjectClass(env, service) 794 | notify := jni.GetMethodID(env, cls, "notify", "(Ljava/lang/String;Ljava/lang/String;)V") 795 | jtitle := jni.JavaString(env, title) 796 | jmessage := jni.JavaString(env, msg) 797 | return jni.CallVoidMethod(env, service, notify, jni.Value(jtitle), jni.Value(jmessage)) 798 | }) 799 | } 800 | 801 | func (a *App) notify(state BackendState) { 802 | select { 803 | case <-a.netStates: 804 | default: 805 | } 806 | a.netStates <- state 807 | ready := jni.Bool(state.State >= ipn.Stopped) 808 | if err := a.callVoidMethod(a.appCtx, "setTileReady", "(Z)V", jni.Value(ready)); err != nil { 809 | fatalErr(err) 810 | } 811 | } 812 | 813 | func (a *App) setPrefs(prefs *ipn.Prefs) { 814 | wantRunning := jni.Bool(prefs.WantRunning) 815 | if err := a.callVoidMethod(a.appCtx, "setTileStatus", "(Z)V", jni.Value(wantRunning)); err != nil { 816 | fatalErr(err) 817 | } 818 | select { 819 | case <-a.prefs: 820 | default: 821 | } 822 | a.prefs <- prefs 823 | } 824 | 825 | func (a *App) setURL(url string) { 826 | select { 827 | case <-a.browseURLs: 828 | default: 829 | } 830 | a.browseURLs <- url 831 | } 832 | 833 | func (a *App) runUI() error { 834 | w := app.NewWindow() 835 | ui, err := newUI(a.store) 836 | if err != nil { 837 | return err 838 | } 839 | var ops op.Ops 840 | state := new(clientState) 841 | var ( 842 | // activity is the most recent Android Activity reference as reported 843 | // by Gio ViewEvents. 844 | activity jni.Object 845 | // files is list of files from the most recent file sharing intent. 846 | files []File 847 | ) 848 | deleteActivityRef := func() { 849 | if activity == 0 { 850 | return 851 | } 852 | jni.Do(a.jvm, func(env *jni.Env) error { 853 | jni.DeleteGlobalRef(env, activity) 854 | return nil 855 | }) 856 | activity = 0 857 | } 858 | defer deleteActivityRef() 859 | for { 860 | select { 861 | case <-onVPNClosed: 862 | requestBackend(ConnectEvent{Enable: false}) 863 | case tok := <-onGoogleToken: 864 | ui.signinType = noSignin 865 | if tok != "" { 866 | requestBackend(OAuth2Event{ 867 | Token: &tailcfg.Oauth2Token{ 868 | AccessToken: tok, 869 | TokenType: ipn.GoogleIDTokenType, 870 | }, 871 | }) 872 | } else { 873 | // Warn about possible debug certificate. 874 | if !a.isReleaseSigned() { 875 | ui.ShowMessage("Google Sign-In failed because the app is not signed for Play Store") 876 | w.Invalidate() 877 | } 878 | } 879 | case p := <-a.prefs: 880 | ui.enabled.Value = p.WantRunning 881 | ui.runningExit = p.AdvertisesExitNode() 882 | ui.exitLAN.Value = p.ExitNodeAllowLANAccess 883 | w.Invalidate() 884 | case url := <-a.browseURLs: 885 | ui.signinType = noSignin 886 | if a.isTV() { 887 | ui.ShowQRCode(url) 888 | } else { 889 | state.browseURL = url 890 | } 891 | w.Invalidate() 892 | a.updateState(activity, state) 893 | case newState := <-a.netStates: 894 | oldState := state.backend.State 895 | state.backend = newState 896 | a.updateState(activity, state) 897 | w.Invalidate() 898 | if activity != 0 { 899 | newState := state.backend.State 900 | // Start VPN if we just logged in. 901 | if oldState <= ipn.Stopped && newState > ipn.Stopped { 902 | if err := a.prepareVPN(activity); err != nil { 903 | fatalErr(err) 904 | } 905 | } 906 | } 907 | case <-onVPNPrepared: 908 | if state.backend.State > ipn.Stopped { 909 | if err := a.callVoidMethod(a.appCtx, "startVPN", "()V"); err != nil { 910 | return err 911 | } 912 | if activity != 0 { 913 | if err := a.callVoidMethod(a.appCtx, "requestWriteStoragePermission", "(Landroid/app/Activity;)V", jni.Value(activity)); err != nil { 914 | return err 915 | } 916 | } 917 | } 918 | case <-onVPNRevoked: 919 | ui.ShowMessage("VPN access denied or another VPN service is always-on") 920 | w.Invalidate() 921 | case files = <-onFileShare: 922 | ui.ShowShareDialog() 923 | w.Invalidate() 924 | case t := <-a.targetsLoaded: 925 | ui.FillShareDialog(t.Targets, t.Err) 926 | w.Invalidate() 927 | case <-a.invalidates: 928 | w.Invalidate() 929 | case e := <-w.Events(): 930 | switch e := e.(type) { 931 | case app.ViewEvent: 932 | deleteActivityRef() 933 | view := jni.Object(e.View) 934 | if view == 0 { 935 | break 936 | } 937 | activity = a.contextForView(view) 938 | w.Invalidate() 939 | a.attachPeer(activity) 940 | if state.backend.State > ipn.Stopped { 941 | if err := a.prepareVPN(activity); err != nil { 942 | return err 943 | } 944 | } 945 | case system.DestroyEvent: 946 | return e.Err 947 | case *system.CommandEvent: 948 | if e.Type == system.CommandBack { 949 | if ui.onBack() { 950 | e.Cancel = true 951 | w.Invalidate() 952 | } 953 | } 954 | case system.FrameEvent: 955 | ins := e.Insets 956 | e.Insets = system.Insets{} 957 | gtx := layout.NewContext(&ops, e) 958 | events := ui.layout(gtx, ins, state) 959 | e.Frame(gtx.Ops) 960 | a.processUIEvents(w, events, activity, state, files) 961 | } 962 | } 963 | } 964 | } 965 | 966 | func (a *App) isTV() bool { 967 | var istv bool 968 | err := jni.Do(a.jvm, func(env *jni.Env) error { 969 | cls := jni.GetObjectClass(env, a.appCtx) 970 | m := jni.GetMethodID(env, cls, "isTV", "()Z") 971 | b, err := jni.CallBooleanMethod(env, a.appCtx, m) 972 | istv = b 973 | return err 974 | }) 975 | if err != nil { 976 | fatalErr(err) 977 | } 978 | return istv 979 | } 980 | 981 | // isReleaseSigned reports whether the app is signed with a release 982 | // signature. 983 | func (a *App) isReleaseSigned() bool { 984 | var cert []byte 985 | err := jni.Do(a.jvm, func(env *jni.Env) error { 986 | cls := jni.GetObjectClass(env, a.appCtx) 987 | m := jni.GetMethodID(env, cls, "getPackageCertificate", "()[B") 988 | str, err := jni.CallObjectMethod(env, a.appCtx, m) 989 | if err != nil { 990 | return err 991 | } 992 | cert = jni.GetByteArrayElements(env, jni.ByteArray(str)) 993 | return nil 994 | }) 995 | if err != nil { 996 | fatalErr(err) 997 | } 998 | h := sha1.New() 999 | h.Write(cert) 1000 | fingerprint := h.Sum(nil) 1001 | hex := fmt.Sprintf("%x", fingerprint) 1002 | // Strip colons and convert to lower case to ease comparing. 1003 | wantFingerprint := strings.ReplaceAll(strings.ToLower(releaseCertFingerprint), ":", "") 1004 | return hex == wantFingerprint 1005 | } 1006 | 1007 | // attachPeer registers an Android Fragment instance for 1008 | // handling onActivityResult callbacks. 1009 | func (a *App) attachPeer(act jni.Object) { 1010 | err := a.callVoidMethod(a.appCtx, "attachPeer", "(Landroid/app/Activity;)V", jni.Value(act)) 1011 | if err != nil { 1012 | fatalErr(err) 1013 | } 1014 | } 1015 | 1016 | func (a *App) updateState(act jni.Object, state *clientState) { 1017 | if act != 0 && state.browseURL != "" { 1018 | a.browseToURL(act, state.browseURL) 1019 | state.browseURL = "" 1020 | } 1021 | 1022 | netmap := state.backend.NetworkMap 1023 | var ( 1024 | peers []*tailcfg.Node 1025 | myID tailcfg.UserID 1026 | ) 1027 | if netmap != nil { 1028 | peers = netmap.Peers 1029 | myID = netmap.User 1030 | } 1031 | // Split into sections. 1032 | users := make(map[tailcfg.UserID]struct{}) 1033 | var uiPeers []UIPeer 1034 | for _, p := range peers { 1035 | if q := state.query; q != "" { 1036 | // Filter peers according to search query. 1037 | host := strings.ToLower(p.Hostinfo.Hostname()) 1038 | name := strings.ToLower(p.Name) 1039 | var addr string 1040 | if len(p.Addresses) > 0 { 1041 | addr = p.Addresses[0].IP().String() 1042 | } 1043 | if !strings.Contains(host, q) && !strings.Contains(name, q) && !strings.Contains(addr, q) { 1044 | continue 1045 | } 1046 | } 1047 | users[p.User] = struct{}{} 1048 | uiPeers = append(uiPeers, UIPeer{ 1049 | Owner: p.User, 1050 | Peer: p, 1051 | }) 1052 | } 1053 | // Add section (user) headers. 1054 | for u := range users { 1055 | name := netmap.UserProfiles[u].DisplayName 1056 | name = strings.ToUpper(name) 1057 | uiPeers = append(uiPeers, UIPeer{Owner: u, Name: name}) 1058 | } 1059 | sort.Slice(uiPeers, func(i, j int) bool { 1060 | lhs, rhs := uiPeers[i], uiPeers[j] 1061 | if lu, ru := lhs.Owner, rhs.Owner; ru != lu { 1062 | // Sort own peers first. 1063 | if lu == myID { 1064 | return true 1065 | } 1066 | if ru == myID { 1067 | return false 1068 | } 1069 | return lu < ru 1070 | } 1071 | lp, rp := lhs.Peer, rhs.Peer 1072 | // Sort headers first. 1073 | if lp == nil { 1074 | return true 1075 | } 1076 | if rp == nil { 1077 | return false 1078 | } 1079 | lName := lp.DisplayName(lp.User == myID) 1080 | rName := rp.DisplayName(rp.User == myID) 1081 | return lName < rName || lName == rName && lp.ID < rp.ID 1082 | }) 1083 | state.Peers = uiPeers 1084 | } 1085 | 1086 | func (a *App) prepareVPN(act jni.Object) error { 1087 | return a.callVoidMethod(a.appCtx, "prepareVPN", "(Landroid/app/Activity;I)V", 1088 | jni.Value(act), jni.Value(requestPrepareVPN)) 1089 | } 1090 | 1091 | func requestBackend(e UIEvent) { 1092 | go func() { 1093 | backendEvents <- e 1094 | }() 1095 | } 1096 | 1097 | func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState, files []File) { 1098 | for _, e := range events { 1099 | switch e := e.(type) { 1100 | case ReauthEvent: 1101 | method, _ := a.store.ReadString(loginMethodPrefKey, loginMethodWeb) 1102 | switch method { 1103 | case loginMethodGoogle: 1104 | a.googleSignIn(act) 1105 | default: 1106 | requestBackend(WebAuthEvent{}) 1107 | } 1108 | case BugEvent: 1109 | backendLogID, _ := a.logIDPublicAtomic.Load().(string) 1110 | logMarker := fmt.Sprintf("BUG-%v-%v-%v", backendLogID, time.Now().UTC().Format("20060102150405Z"), randHex(8)) 1111 | log.Printf("user bugreport: %s", logMarker) 1112 | w.WriteClipboard(logMarker) 1113 | case BeExitNodeEvent: 1114 | requestBackend(e) 1115 | case ExitAllowLANEvent: 1116 | requestBackend(e) 1117 | case WebAuthEvent: 1118 | a.store.WriteString(loginMethodPrefKey, loginMethodWeb) 1119 | requestBackend(e) 1120 | case SetLoginServerEvent: 1121 | a.store.WriteString(customLoginServerPrefKey, e.URL) 1122 | requestBackend(e) 1123 | case LogoutEvent: 1124 | a.signOut() 1125 | requestBackend(e) 1126 | case ConnectEvent: 1127 | requestBackend(e) 1128 | case RouteAllEvent: 1129 | requestBackend(e) 1130 | case CopyEvent: 1131 | w.WriteClipboard(e.Text) 1132 | case GoogleAuthEvent: 1133 | a.store.WriteString(loginMethodPrefKey, loginMethodGoogle) 1134 | a.googleSignIn(act) 1135 | case SearchEvent: 1136 | state.query = strings.ToLower(e.Query) 1137 | a.updateState(act, state) 1138 | case FileSendEvent: 1139 | a.sendFiles(e, files) 1140 | } 1141 | } 1142 | } 1143 | 1144 | func (a *App) sendFiles(e FileSendEvent, files []File) { 1145 | go func() { 1146 | var totalSize int64 1147 | for _, f := range files { 1148 | totalSize += f.Size 1149 | } 1150 | if totalSize == 0 { 1151 | totalSize = 1 1152 | } 1153 | var totalSent int64 1154 | progress := func(n int64) { 1155 | totalSent += n 1156 | e.Updates(FileSendInfo{ 1157 | State: FileSendTransferring, 1158 | Progress: float64(totalSent) / float64(totalSize), 1159 | }) 1160 | a.invalidate() 1161 | } 1162 | defer a.invalidate() 1163 | for _, f := range files { 1164 | if err := a.sendFile(e.Context, e.Target, f, progress); err != nil { 1165 | if errors.Is(err, context.Canceled) { 1166 | return 1167 | } 1168 | e.Updates(FileSendInfo{ 1169 | State: FileSendFailed, 1170 | }) 1171 | return 1172 | } 1173 | } 1174 | e.Updates(FileSendInfo{ 1175 | State: FileSendComplete, 1176 | }) 1177 | }() 1178 | } 1179 | 1180 | func (a *App) invalidate() { 1181 | select { 1182 | case a.invalidates <- struct{}{}: 1183 | default: 1184 | } 1185 | } 1186 | 1187 | func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget, f File, progress func(n int64)) error { 1188 | var body io.Reader 1189 | switch f.Type { 1190 | case FileTypeText: 1191 | body = strings.NewReader(f.Text) 1192 | case FileTypeURI: 1193 | f, err := a.openURI(f.URI, "r") 1194 | if err != nil { 1195 | return err 1196 | } 1197 | defer f.Close() 1198 | body = f 1199 | default: 1200 | panic("unknown file type") 1201 | } 1202 | body = &progressReader{r: body, size: f.Size, progress: progress} 1203 | dstURL := target.PeerAPIURL + "/v0/put/" + url.PathEscape(f.Name) 1204 | req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, body) 1205 | if err != nil { 1206 | return err 1207 | } 1208 | req.ContentLength = f.Size 1209 | res, err := http.DefaultClient.Do(req) 1210 | if err != nil { 1211 | return err 1212 | } 1213 | defer res.Body.Close() 1214 | if res.StatusCode != 200 { 1215 | return fmt.Errorf("PUT failed: %s", res.Status) 1216 | } 1217 | return nil 1218 | } 1219 | 1220 | // progressReader wraps an io.Reader to call a progress function 1221 | // on every non-zero Read. 1222 | type progressReader struct { 1223 | r io.Reader 1224 | bytes int64 1225 | size int64 1226 | eof bool 1227 | progress func(n int64) 1228 | } 1229 | 1230 | func (r *progressReader) Read(p []byte) (int, error) { 1231 | n, err := r.r.Read(p) 1232 | // The request body may be read after http.Client.Do returns, see 1233 | // https://github.com/golang/go/issues/30597. Don't update progress if the 1234 | // file has been read. 1235 | r.eof = r.eof || errors.Is(err, io.EOF) 1236 | if !r.eof && r.bytes < r.size { 1237 | r.progress(int64(n)) 1238 | r.bytes += int64(n) 1239 | } 1240 | return n, err 1241 | } 1242 | 1243 | func (a *App) signOut() { 1244 | if googleClass == 0 { 1245 | return 1246 | } 1247 | err := jni.Do(a.jvm, func(env *jni.Env) error { 1248 | m := jni.GetStaticMethodID(env, googleClass, 1249 | "googleSignOut", "(Landroid/content/Context;)V") 1250 | return jni.CallStaticVoidMethod(env, googleClass, m, jni.Value(a.appCtx)) 1251 | }) 1252 | if err != nil { 1253 | fatalErr(err) 1254 | } 1255 | } 1256 | 1257 | func (a *App) googleSignIn(act jni.Object) { 1258 | if act == 0 || googleClass == 0 { 1259 | return 1260 | } 1261 | err := jni.Do(a.jvm, func(env *jni.Env) error { 1262 | sid := jni.JavaString(env, serverOAuthID) 1263 | m := jni.GetStaticMethodID(env, googleClass, 1264 | "googleSignIn", "(Landroid/app/Activity;Ljava/lang/String;I)V") 1265 | return jni.CallStaticVoidMethod(env, googleClass, m, 1266 | jni.Value(act), jni.Value(sid), jni.Value(requestSignin)) 1267 | }) 1268 | if err != nil { 1269 | fatalErr(err) 1270 | } 1271 | } 1272 | 1273 | func (a *App) browseToURL(act jni.Object, url string) { 1274 | if act == 0 { 1275 | return 1276 | } 1277 | err := jni.Do(a.jvm, func(env *jni.Env) error { 1278 | jurl := jni.JavaString(env, url) 1279 | return a.callVoidMethod(a.appCtx, "showURL", "(Landroid/app/Activity;Ljava/lang/String;)V", jni.Value(act), jni.Value(jurl)) 1280 | }) 1281 | if err != nil { 1282 | fatalErr(err) 1283 | } 1284 | } 1285 | 1286 | func (a *App) callVoidMethod(obj jni.Object, name, sig string, args ...jni.Value) error { 1287 | if obj == 0 { 1288 | panic("invalid object") 1289 | } 1290 | return jni.Do(a.jvm, func(env *jni.Env) error { 1291 | cls := jni.GetObjectClass(env, obj) 1292 | m := jni.GetMethodID(env, cls, name, sig) 1293 | return jni.CallVoidMethod(env, obj, m, args...) 1294 | }) 1295 | } 1296 | 1297 | // activityForView calls View.getContext and returns a global 1298 | // reference to the result. 1299 | func (a *App) contextForView(view jni.Object) jni.Object { 1300 | if view == 0 { 1301 | panic("invalid object") 1302 | } 1303 | var ctx jni.Object 1304 | err := jni.Do(a.jvm, func(env *jni.Env) error { 1305 | cls := jni.GetObjectClass(env, view) 1306 | m := jni.GetMethodID(env, cls, "getContext", "()Landroid/content/Context;") 1307 | var err error 1308 | ctx, err = jni.CallObjectMethod(env, view, m) 1309 | ctx = jni.NewGlobalRef(env, ctx) 1310 | return err 1311 | }) 1312 | if err != nil { 1313 | panic(err) 1314 | } 1315 | return ctx 1316 | } 1317 | 1318 | // Report interfaces in the device in net.Interface format. 1319 | func (a *App) getInterfaces() ([]interfaces.Interface, error) { 1320 | var ifaceString string 1321 | err := jni.Do(a.jvm, func(env *jni.Env) error { 1322 | cls := jni.GetObjectClass(env, a.appCtx) 1323 | m := jni.GetMethodID(env, cls, "getInterfacesAsString", "()Ljava/lang/String;") 1324 | n, err := jni.CallObjectMethod(env, a.appCtx, m) 1325 | ifaceString = jni.GoString(env, jni.String(n)) 1326 | return err 1327 | 1328 | }) 1329 | var ifaces []interfaces.Interface 1330 | if err != nil { 1331 | return ifaces, err 1332 | } 1333 | 1334 | for _, iface := range strings.Split(ifaceString, "\n") { 1335 | // Example of the strings we're processing: 1336 | // wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24 1337 | // r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64 1338 | // mnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64 1339 | 1340 | if strings.TrimSpace(iface) == "" { 1341 | continue 1342 | } 1343 | 1344 | fields := strings.Split(iface, "|") 1345 | if len(fields) != 2 { 1346 | log.Printf("getInterfaces: unable to split %q", iface) 1347 | continue 1348 | } 1349 | 1350 | var name string 1351 | var index, mtu int 1352 | var up, broadcast, loopback, pointToPoint, multicast bool 1353 | _, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t", 1354 | &name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast) 1355 | if err != nil { 1356 | log.Printf("getInterfaces: unable to parse %q: %v", iface, err) 1357 | continue 1358 | } 1359 | 1360 | newIf := interfaces.Interface{ 1361 | Interface: &net.Interface{ 1362 | Name: name, 1363 | Index: index, 1364 | MTU: mtu, 1365 | }, 1366 | AltAddrs: []net.Addr{}, // non-nil to avoid Go using netlink 1367 | } 1368 | if up { 1369 | newIf.Flags |= net.FlagUp 1370 | } 1371 | if broadcast { 1372 | newIf.Flags |= net.FlagBroadcast 1373 | } 1374 | if loopback { 1375 | newIf.Flags |= net.FlagLoopback 1376 | } 1377 | if pointToPoint { 1378 | newIf.Flags |= net.FlagPointToPoint 1379 | } 1380 | if multicast { 1381 | newIf.Flags |= net.FlagMulticast 1382 | } 1383 | 1384 | addrs := strings.Trim(fields[1], " \n") 1385 | for _, addr := range strings.Split(addrs, " ") { 1386 | ip, err := netaddr.ParseIPPrefix(addr) 1387 | if err == nil { 1388 | newIf.AltAddrs = append(newIf.AltAddrs, ip.IPNet()) 1389 | } 1390 | } 1391 | 1392 | ifaces = append(ifaces, newIf) 1393 | } 1394 | 1395 | return ifaces, nil 1396 | } 1397 | 1398 | func fatalErr(err error) { 1399 | // TODO: expose in UI. 1400 | log.Printf("fatal error: %v", err) 1401 | } 1402 | 1403 | func randHex(n int) string { 1404 | b := make([]byte, n) 1405 | rand.Read(b) 1406 | return hex.EncodeToString(b) 1407 | } 1408 | -------------------------------------------------------------------------------- /cmd/tailscale/multitun.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "os" 9 | 10 | "golang.zx2c4.com/wireguard/tun" 11 | ) 12 | 13 | // multiTUN implements a tun.Device that supports multiple 14 | // underlying devices. This is necessary because Android VPN devices 15 | // have static configurations and wgengine.NewUserspaceEngine 16 | // assumes a single static tun.Device. 17 | type multiTUN struct { 18 | // devices is for adding new devices. 19 | devices chan tun.Device 20 | // event is the combined event channel from all active devices. 21 | events chan tun.Event 22 | 23 | close chan struct{} 24 | closeErr chan error 25 | 26 | reads chan ioRequest 27 | writes chan ioRequest 28 | flushes chan chan error 29 | mtus chan chan mtuReply 30 | names chan chan nameReply 31 | shutdowns chan struct{} 32 | shutdownDone chan struct{} 33 | } 34 | 35 | // tunDevice wraps and drives a single run.Device. 36 | type tunDevice struct { 37 | dev tun.Device 38 | // close closes the device. 39 | close chan struct{} 40 | closeDone chan error 41 | // readDone is notified when the read goroutine is done. 42 | readDone chan struct{} 43 | } 44 | 45 | type ioRequest struct { 46 | data []byte 47 | offset int 48 | reply chan<- ioReply 49 | } 50 | 51 | type ioReply struct { 52 | bytes int 53 | err error 54 | } 55 | 56 | type mtuReply struct { 57 | mtu int 58 | err error 59 | } 60 | 61 | type nameReply struct { 62 | name string 63 | err error 64 | } 65 | 66 | func newTUNDevices() *multiTUN { 67 | d := &multiTUN{ 68 | devices: make(chan tun.Device), 69 | events: make(chan tun.Event), 70 | close: make(chan struct{}), 71 | closeErr: make(chan error), 72 | reads: make(chan ioRequest), 73 | writes: make(chan ioRequest), 74 | flushes: make(chan chan error), 75 | mtus: make(chan chan mtuReply), 76 | names: make(chan chan nameReply), 77 | shutdowns: make(chan struct{}), 78 | shutdownDone: make(chan struct{}), 79 | } 80 | go d.run() 81 | return d 82 | } 83 | 84 | func (d *multiTUN) run() { 85 | var devices []*tunDevice 86 | // readDone is the readDone channel of the device being read from. 87 | var readDone chan struct{} 88 | // runDone is the closeDone channel of the device being written to. 89 | var runDone chan error 90 | for { 91 | select { 92 | case <-readDone: 93 | // The oldest device has reached EOF, replace it. 94 | n := copy(devices, devices[1:]) 95 | devices = devices[:n] 96 | if len(devices) > 0 { 97 | // Start reading from the next device. 98 | dev := devices[0] 99 | readDone = dev.readDone 100 | go d.readFrom(dev) 101 | } 102 | case <-runDone: 103 | // A device completed runDevice, replace it. 104 | if len(devices) > 0 { 105 | dev := devices[len(devices)-1] 106 | runDone = dev.closeDone 107 | go d.runDevice(dev) 108 | } 109 | case <-d.shutdowns: 110 | // Shut down all devices. 111 | for _, dev := range devices { 112 | close(dev.close) 113 | <-dev.closeDone 114 | <-dev.readDone 115 | } 116 | devices = nil 117 | d.shutdownDone <- struct{}{} 118 | case <-d.close: 119 | var derr error 120 | for _, dev := range devices { 121 | if err := <-dev.closeDone; err != nil { 122 | derr = err 123 | } 124 | } 125 | d.closeErr <- derr 126 | return 127 | case dev := <-d.devices: 128 | if len(devices) > 0 { 129 | // Ask the most recent device to stop. 130 | prev := devices[len(devices)-1] 131 | close(prev.close) 132 | } 133 | wrap := &tunDevice{ 134 | dev: dev, 135 | close: make(chan struct{}), 136 | closeDone: make(chan error), 137 | readDone: make(chan struct{}, 1), 138 | } 139 | if len(devices) == 0 { 140 | // Start using this first device. 141 | readDone = wrap.readDone 142 | go d.readFrom(wrap) 143 | runDone = wrap.closeDone 144 | go d.runDevice(wrap) 145 | } 146 | devices = append(devices, wrap) 147 | case f := <-d.flushes: 148 | var err error 149 | if len(devices) > 0 { 150 | dev := devices[len(devices)-1] 151 | err = dev.dev.Flush() 152 | } 153 | f <- err 154 | case m := <-d.mtus: 155 | r := mtuReply{mtu: defaultMTU} 156 | if len(devices) > 0 { 157 | dev := devices[len(devices)-1] 158 | r.mtu, r.err = dev.dev.MTU() 159 | } 160 | m <- r 161 | case n := <-d.names: 162 | var r nameReply 163 | if len(devices) > 0 { 164 | dev := devices[len(devices)-1] 165 | r.name, r.err = dev.dev.Name() 166 | } 167 | n <- r 168 | } 169 | } 170 | } 171 | 172 | func (d *multiTUN) readFrom(dev *tunDevice) { 173 | defer func() { 174 | dev.readDone <- struct{}{} 175 | }() 176 | for { 177 | select { 178 | case r := <-d.reads: 179 | n, err := dev.dev.Read(r.data, r.offset) 180 | stop := false 181 | if err != nil { 182 | select { 183 | case <-dev.close: 184 | stop = true 185 | err = nil 186 | default: 187 | } 188 | } 189 | r.reply <- ioReply{n, err} 190 | if stop { 191 | return 192 | } 193 | case <-d.close: 194 | return 195 | } 196 | } 197 | } 198 | 199 | func (d *multiTUN) runDevice(dev *tunDevice) { 200 | defer func() { 201 | // The documentation for https://developer.android.com/reference/android/net/VpnService.Builder#establish() 202 | // states that "Therefore, after draining the old file 203 | // descriptor...", but pending Reads are never unblocked 204 | // when a new descriptor is created. 205 | // 206 | // Close it instead and hope that no packets are lost. 207 | dev.closeDone <- dev.dev.Close() 208 | }() 209 | // Pump device events. 210 | go func() { 211 | for { 212 | select { 213 | case e := <-dev.dev.Events(): 214 | d.events <- e 215 | case <-dev.close: 216 | return 217 | } 218 | } 219 | }() 220 | for { 221 | select { 222 | case w := <-d.writes: 223 | n, err := dev.dev.Write(w.data, w.offset) 224 | w.reply <- ioReply{n, err} 225 | case <-dev.close: 226 | // Device closed. 227 | return 228 | case <-d.close: 229 | // Multi-device closed. 230 | return 231 | } 232 | } 233 | } 234 | 235 | func (d *multiTUN) add(dev tun.Device) { 236 | d.devices <- dev 237 | } 238 | 239 | func (d *multiTUN) File() *os.File { 240 | // The underlying file descriptor is not constant on Android. 241 | // Let's hope no-one uses it. 242 | panic("not available on Android") 243 | } 244 | 245 | func (d *multiTUN) Read(data []byte, offset int) (int, error) { 246 | r := make(chan ioReply) 247 | d.reads <- ioRequest{data, offset, r} 248 | rep := <-r 249 | return rep.bytes, rep.err 250 | } 251 | 252 | func (d *multiTUN) Write(data []byte, offset int) (int, error) { 253 | r := make(chan ioReply) 254 | d.writes <- ioRequest{data, offset, r} 255 | rep := <-r 256 | return rep.bytes, rep.err 257 | } 258 | 259 | func (d *multiTUN) Flush() error { 260 | r := make(chan error) 261 | d.flushes <- r 262 | return <-r 263 | } 264 | 265 | func (d *multiTUN) MTU() (int, error) { 266 | r := make(chan mtuReply) 267 | d.mtus <- r 268 | rep := <-r 269 | return rep.mtu, rep.err 270 | } 271 | 272 | func (d *multiTUN) Name() (string, error) { 273 | r := make(chan nameReply) 274 | d.names <- r 275 | rep := <-r 276 | return rep.name, rep.err 277 | } 278 | 279 | func (d *multiTUN) Events() chan tun.Event { 280 | return d.events 281 | } 282 | 283 | func (d *multiTUN) Shutdown() { 284 | d.shutdowns <- struct{}{} 285 | <-d.shutdownDone 286 | } 287 | 288 | func (d *multiTUN) Close() error { 289 | close(d.close) 290 | return <-d.closeErr 291 | } 292 | -------------------------------------------------------------------------------- /cmd/tailscale/pprof.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build pprof 6 | // +build pprof 7 | 8 | package main 9 | 10 | import ( 11 | "net/http" 12 | _ "net/http/pprof" 13 | ) 14 | 15 | func init() { 16 | go func() { 17 | http.ListenAndServe(":6060", nil) 18 | }() 19 | } 20 | -------------------------------------------------------------------------------- /cmd/tailscale/store.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "encoding/base64" 9 | 10 | "tailscale.com/ipn" 11 | 12 | "github.com/tailscale/tailscale-android/jni" 13 | ) 14 | 15 | // stateStore is the Go interface for a persistent storage 16 | // backend by androidx.security.crypto.EncryptedSharedPreferences (see 17 | // App.java). 18 | type stateStore struct { 19 | jvm *jni.JVM 20 | // appCtx is the global Android app context. 21 | appCtx jni.Object 22 | 23 | // Cached method ids on appCtx. 24 | encrypt jni.MethodID 25 | decrypt jni.MethodID 26 | } 27 | 28 | func newStateStore(jvm *jni.JVM, appCtx jni.Object) *stateStore { 29 | s := &stateStore{ 30 | jvm: jvm, 31 | appCtx: appCtx, 32 | } 33 | jni.Do(jvm, func(env *jni.Env) error { 34 | appCls := jni.GetObjectClass(env, appCtx) 35 | s.encrypt = jni.GetMethodID( 36 | env, appCls, 37 | "encryptToPref", "(Ljava/lang/String;Ljava/lang/String;)V", 38 | ) 39 | s.decrypt = jni.GetMethodID( 40 | env, appCls, 41 | "decryptFromPref", "(Ljava/lang/String;)Ljava/lang/String;", 42 | ) 43 | return nil 44 | }) 45 | return s 46 | } 47 | 48 | func prefKeyFor(id ipn.StateKey) string { 49 | return "statestore-" + string(id) 50 | } 51 | 52 | func (s *stateStore) ReadString(key string, def string) (string, error) { 53 | data, err := s.read(key) 54 | if err != nil { 55 | return def, err 56 | } 57 | if data == nil { 58 | return def, nil 59 | } 60 | return string(data), nil 61 | } 62 | 63 | func (s *stateStore) WriteString(key string, val string) error { 64 | return s.write(key, []byte(val)) 65 | } 66 | 67 | func (s *stateStore) ReadBool(key string, def bool) (bool, error) { 68 | data, err := s.read(key) 69 | if err != nil { 70 | return def, err 71 | } 72 | if data == nil { 73 | return def, nil 74 | } 75 | return string(data) == "true", nil 76 | } 77 | 78 | func (s *stateStore) WriteBool(key string, val bool) error { 79 | data := []byte("false") 80 | if val { 81 | data = []byte("true") 82 | } 83 | return s.write(key, data) 84 | } 85 | 86 | func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) { 87 | state, err := s.read(prefKeyFor(id)) 88 | if err != nil { 89 | return nil, err 90 | } 91 | if state == nil { 92 | return nil, ipn.ErrStateNotExist 93 | } 94 | return state, nil 95 | } 96 | 97 | func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error { 98 | prefKey := prefKeyFor(id) 99 | return s.write(prefKey, bs) 100 | } 101 | 102 | func (s *stateStore) read(key string) ([]byte, error) { 103 | var data []byte 104 | err := jni.Do(s.jvm, func(env *jni.Env) error { 105 | jfile := jni.JavaString(env, key) 106 | plain, err := jni.CallObjectMethod(env, s.appCtx, s.decrypt, 107 | jni.Value(jfile)) 108 | if err != nil { 109 | return err 110 | } 111 | b64 := jni.GoString(env, jni.String(plain)) 112 | if b64 == "" { 113 | return nil 114 | } 115 | data, err = base64.RawStdEncoding.DecodeString(b64) 116 | return err 117 | }) 118 | return data, err 119 | } 120 | 121 | func (s *stateStore) write(key string, value []byte) error { 122 | bs64 := base64.RawStdEncoding.EncodeToString(value) 123 | err := jni.Do(s.jvm, func(env *jni.Env) error { 124 | jfile := jni.JavaString(env, key) 125 | jplain := jni.JavaString(env, bs64) 126 | err := jni.CallVoidMethod(env, s.appCtx, s.encrypt, 127 | jni.Value(jfile), jni.Value(jplain)) 128 | if err != nil { 129 | return err 130 | } 131 | return nil 132 | }) 133 | return err 134 | } 135 | -------------------------------------------------------------------------------- /cmd/tailscale/tailscale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/cmd/tailscale/tailscale.png -------------------------------------------------------------------------------- /cmd/tailscale/tools.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build tools 6 | // +build tools 7 | 8 | package main 9 | 10 | import ( 11 | _ "gioui.org/cmd/gogio" 12 | ) 13 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "android": { 4 | "inputs": { 5 | "devshell": "devshell", 6 | "flake-utils": "flake-utils_2", 7 | "nixpkgs": [ 8 | "nixpkgs" 9 | ] 10 | }, 11 | "locked": { 12 | "lastModified": 1648412532, 13 | "narHash": "sha256-zh0rLcppJ5i2Bh8oBWjBhDvgOrMhDGdXINbp3bhrs0U=", 14 | "owner": "tadfisher", 15 | "repo": "android-nixpkgs", 16 | "rev": "b1318b23926260685dbb09dd127f38c917fc7441", 17 | "type": "github" 18 | }, 19 | "original": { 20 | "owner": "tadfisher", 21 | "repo": "android-nixpkgs", 22 | "type": "github" 23 | } 24 | }, 25 | "devshell": { 26 | "inputs": { 27 | "flake-utils": "flake-utils", 28 | "nixpkgs": "nixpkgs" 29 | }, 30 | "locked": { 31 | "lastModified": 1647857022, 32 | "narHash": "sha256-Aw70NWLOIwKhT60MHDGjgWis3DP3faCzr6ap9CSayek=", 33 | "owner": "numtide", 34 | "repo": "devshell", 35 | "rev": "0a5ff74dacb9ea22614f64e61aeb3ca0bf0e7311", 36 | "type": "github" 37 | }, 38 | "original": { 39 | "owner": "numtide", 40 | "repo": "devshell", 41 | "type": "github" 42 | } 43 | }, 44 | "flake-utils": { 45 | "locked": { 46 | "lastModified": 1642700792, 47 | "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", 48 | "owner": "numtide", 49 | "repo": "flake-utils", 50 | "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", 51 | "type": "github" 52 | }, 53 | "original": { 54 | "owner": "numtide", 55 | "repo": "flake-utils", 56 | "type": "github" 57 | } 58 | }, 59 | "flake-utils_2": { 60 | "locked": { 61 | "lastModified": 1648297722, 62 | "narHash": "sha256-W+qlPsiZd8F3XkzXOzAoR+mpFqzm3ekQkJNa+PIh1BQ=", 63 | "owner": "numtide", 64 | "repo": "flake-utils", 65 | "rev": "0f8662f1319ad6abf89b3380dd2722369fc51ade", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "numtide", 70 | "repo": "flake-utils", 71 | "type": "github" 72 | } 73 | }, 74 | "nixpkgs": { 75 | "locked": { 76 | "lastModified": 1643381941, 77 | "narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=", 78 | "owner": "NixOS", 79 | "repo": "nixpkgs", 80 | "rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "owner": "NixOS", 85 | "ref": "nixpkgs-unstable", 86 | "repo": "nixpkgs", 87 | "type": "github" 88 | } 89 | }, 90 | "nixpkgs_2": { 91 | "locked": { 92 | "lastModified": 1648484058, 93 | "narHash": "sha256-YJFeh59/HMassannh6JJuy+0l305xFhvUPxWLITRgWk=", 94 | "owner": "NixOS", 95 | "repo": "nixpkgs", 96 | "rev": "e4b25c3f0a8828e39e6194c3f7b142a937a9d51f", 97 | "type": "github" 98 | }, 99 | "original": { 100 | "owner": "NixOS", 101 | "repo": "nixpkgs", 102 | "type": "github" 103 | } 104 | }, 105 | "root": { 106 | "inputs": { 107 | "android": "android", 108 | "nixpkgs": "nixpkgs_2" 109 | } 110 | } 111 | }, 112 | "root": "root", 113 | "version": 7 114 | } 115 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Tailscale build environment"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs"; 6 | android.url = "github:tadfisher/android-nixpkgs"; 7 | android.inputs.nixpkgs.follows = "nixpkgs"; 8 | }; 9 | 10 | outputs = { self, nixpkgs, android }: 11 | let 12 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ]; 13 | forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system); 14 | in 15 | { 16 | devShells = forAllSystems 17 | (system: 18 | let 19 | pkgs = import nixpkgs { 20 | inherit system; 21 | }; 22 | android-sdk = android.sdk.${system} (sdkPkgs: with sdkPkgs; 23 | [ 24 | build-tools-30-0-2 25 | cmdline-tools-latest 26 | platform-tools 27 | platforms-android-31 28 | platforms-android-30 29 | ndk-23-1-7779620 30 | patcher-v4 31 | ]); 32 | in 33 | { 34 | default = (with pkgs; buildFHSUserEnv { 35 | name = "tailscale"; 36 | profile = '' 37 | export ANDROID_SDK_ROOT="${android-sdk}/share/android-sdk" 38 | export JAVA_HOME="${jdk8.home}" 39 | ''; 40 | targetPkgs = pkgs: with pkgs; [ 41 | android-sdk 42 | jdk8 43 | clang 44 | ] ++ (if stdenv.isLinux then [ 45 | vulkan-headers 46 | libxkbcommon 47 | wayland 48 | xorg.libX11 49 | xorg.libXcursor 50 | xorg.libXfixes 51 | libGL 52 | pkgconfig 53 | ] else [ ]); 54 | }).env; 55 | } 56 | ); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/tailscale-android 2 | 3 | go 1.18 4 | 5 | require ( 6 | eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3 7 | gioui.org v0.0.0-20220331105829-a1b5ff059c07 8 | gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6 9 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 10 | golang.org/x/exp v0.0.0-20210722180016-6781d3edade3 11 | golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d 12 | golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478 13 | inet.af/netaddr v0.0.0-20220617031823-097006376321 14 | tailscale.com v1.1.1-0.20220718172352-3c892d106c2e 15 | ) 16 | 17 | require ( 18 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect 19 | gioui.org/shader v1.0.6 // indirect 20 | github.com/akavel/rsrc v0.10.1 // indirect 21 | github.com/akutz/memconn v0.1.0 // indirect 22 | github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect 23 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 24 | github.com/benoitkugler/textlayout v0.0.10 // indirect 25 | github.com/coreos/go-iptables v0.6.0 // indirect 26 | github.com/creack/pty v1.1.17 // indirect 27 | github.com/gioui/uax v0.2.1-0.20220325163150-e3d987515a12 // indirect 28 | github.com/go-ole/go-ole v1.2.6 // indirect 29 | github.com/go-text/typesetting v0.0.0-20220112121102-58fe93c84506 // indirect 30 | github.com/godbus/dbus/v5 v5.0.6 // indirect 31 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 32 | github.com/google/btree v1.0.1 // indirect 33 | github.com/google/go-cmp v0.5.8 // indirect 34 | github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e // indirect 35 | github.com/josharian/native v1.0.0 // indirect 36 | github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect 37 | github.com/klauspost/compress v1.15.4 // indirect 38 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 39 | github.com/mdlayher/genetlink v1.2.0 // indirect 40 | github.com/mdlayher/netlink v1.6.0 // indirect 41 | github.com/mdlayher/sdnotify v1.0.0 // indirect 42 | github.com/mdlayher/socket v0.2.3 // indirect 43 | github.com/mitchellh/go-ps v1.0.0 // indirect 44 | github.com/pkg/errors v0.9.1 // indirect 45 | github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect 46 | github.com/tailscale/golang-x-crypto v0.0.0-20220428210705-0b941c09a5e1 // indirect 47 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 48 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect 49 | github.com/tcnksm/go-httpstat v0.2.0 // indirect 50 | github.com/u-root/u-root v0.8.0 // indirect 51 | github.com/u-root/uio v0.0.0-20210528151154-e40b768296a7 // indirect 52 | github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 // indirect 53 | github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect 54 | go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect 55 | go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect 56 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect 57 | golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect 58 | golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect 59 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect 60 | golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect 61 | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect 62 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 63 | golang.org/x/text v0.3.7 // indirect 64 | golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect 65 | golang.org/x/tools v0.1.11 // indirect 66 | golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect 67 | golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect 68 | golang.zx2c4.com/wireguard/windows v0.4.10 // indirect 69 | gvisor.dev/gvisor v0.0.0-20220407223209-21871174d445 // indirect 70 | nhooyr.io/websocket v1.8.7 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /jni/jni.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package jni implements various helper functions for communicating with the Android JVM 6 | // though JNI. 7 | package jni 8 | 9 | import ( 10 | "errors" 11 | "fmt" 12 | "reflect" 13 | "runtime" 14 | "sync" 15 | "unicode/utf16" 16 | "unsafe" 17 | ) 18 | 19 | /* 20 | #cgo CFLAGS: -Wall 21 | 22 | #include 23 | #include 24 | 25 | static jint jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) { 26 | return (*vm)->AttachCurrentThread(vm, p_env, thr_args); 27 | } 28 | 29 | static jint jni_DetachCurrentThread(JavaVM *vm) { 30 | return (*vm)->DetachCurrentThread(vm); 31 | } 32 | 33 | static jint jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version) { 34 | return (*vm)->GetEnv(vm, (void **)env, version); 35 | } 36 | 37 | static jclass jni_FindClass(JNIEnv *env, const char *name) { 38 | return (*env)->FindClass(env, name); 39 | } 40 | 41 | static jthrowable jni_ExceptionOccurred(JNIEnv *env) { 42 | return (*env)->ExceptionOccurred(env); 43 | } 44 | 45 | static void jni_ExceptionClear(JNIEnv *env) { 46 | (*env)->ExceptionClear(env); 47 | } 48 | 49 | static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) { 50 | return (*env)->GetObjectClass(env, obj); 51 | } 52 | 53 | static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { 54 | return (*env)->GetMethodID(env, clazz, name, sig); 55 | } 56 | 57 | static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { 58 | return (*env)->GetStaticMethodID(env, clazz, name, sig); 59 | } 60 | 61 | static jsize jni_GetStringLength(JNIEnv *env, jstring str) { 62 | return (*env)->GetStringLength(env, str); 63 | } 64 | 65 | static const jchar *jni_GetStringChars(JNIEnv *env, jstring str) { 66 | return (*env)->GetStringChars(env, str, NULL); 67 | } 68 | 69 | static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) { 70 | return (*env)->NewString(env, unicodeChars, len); 71 | } 72 | 73 | static jboolean jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2) { 74 | return (*env)->IsSameObject(env, ref1, ref2); 75 | } 76 | 77 | static jobject jni_NewGlobalRef(JNIEnv *env, jobject obj) { 78 | return (*env)->NewGlobalRef(env, obj); 79 | } 80 | 81 | static void jni_DeleteGlobalRef(JNIEnv *env, jobject obj) { 82 | (*env)->DeleteGlobalRef(env, obj); 83 | } 84 | 85 | static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { 86 | (*env)->CallStaticVoidMethodA(env, cls, method, args); 87 | } 88 | 89 | static jint jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { 90 | return (*env)->CallStaticIntMethodA(env, cls, method, args); 91 | } 92 | 93 | static jobject jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) { 94 | return (*env)->CallStaticObjectMethodA(env, cls, method, args); 95 | } 96 | 97 | static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { 98 | return (*env)->CallObjectMethodA(env, obj, method, args); 99 | } 100 | 101 | static jboolean jni_CallBooleanMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { 102 | return (*env)->CallBooleanMethodA(env, obj, method, args); 103 | } 104 | 105 | static jint jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { 106 | return (*env)->CallIntMethodA(env, obj, method, args); 107 | } 108 | 109 | static void jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) { 110 | (*env)->CallVoidMethodA(env, obj, method, args); 111 | } 112 | 113 | static jbyteArray jni_NewByteArray(JNIEnv *env, jsize length) { 114 | return (*env)->NewByteArray(env, length); 115 | } 116 | 117 | static jboolean *jni_GetBooleanArrayElements(JNIEnv *env, jbooleanArray arr) { 118 | return (*env)->GetBooleanArrayElements(env, arr, NULL); 119 | } 120 | 121 | static void jni_ReleaseBooleanArrayElements(JNIEnv *env, jbooleanArray arr, jboolean *elems, jint mode) { 122 | (*env)->ReleaseBooleanArrayElements(env, arr, elems, mode); 123 | } 124 | 125 | static jbyte *jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr) { 126 | return (*env)->GetByteArrayElements(env, arr, NULL); 127 | } 128 | 129 | static jint *jni_GetIntArrayElements(JNIEnv *env, jintArray arr) { 130 | return (*env)->GetIntArrayElements(env, arr, NULL); 131 | } 132 | 133 | static void jni_ReleaseIntArrayElements(JNIEnv *env, jintArray arr, jint *elems, jint mode) { 134 | (*env)->ReleaseIntArrayElements(env, arr, elems, mode); 135 | } 136 | 137 | static jlong *jni_GetLongArrayElements(JNIEnv *env, jlongArray arr) { 138 | return (*env)->GetLongArrayElements(env, arr, NULL); 139 | } 140 | 141 | static void jni_ReleaseLongArrayElements(JNIEnv *env, jlongArray arr, jlong *elems, jint mode) { 142 | (*env)->ReleaseLongArrayElements(env, arr, elems, mode); 143 | } 144 | 145 | static void jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode) { 146 | (*env)->ReleaseByteArrayElements(env, arr, elems, mode); 147 | } 148 | 149 | static jsize jni_GetArrayLength(JNIEnv *env, jarray arr) { 150 | return (*env)->GetArrayLength(env, arr); 151 | } 152 | 153 | static void jni_DeleteLocalRef(JNIEnv *env, jobject localRef) { 154 | return (*env)->DeleteLocalRef(env, localRef); 155 | } 156 | 157 | static jobject jni_GetObjectArrayElement(JNIEnv *env, jobjectArray array, jsize index) { 158 | return (*env)->GetObjectArrayElement(env, array, index); 159 | } 160 | 161 | static jboolean jni_IsInstanceOf(JNIEnv *env, jobject obj, jclass clazz) { 162 | return (*env)->IsInstanceOf(env, obj, clazz); 163 | } 164 | */ 165 | import "C" 166 | 167 | type JVM C.JavaVM 168 | 169 | type Env C.JNIEnv 170 | 171 | type ( 172 | Class C.jclass 173 | Object C.jobject 174 | MethodID C.jmethodID 175 | String C.jstring 176 | ByteArray C.jbyteArray 177 | ObjectArray C.jobjectArray 178 | BooleanArray C.jbooleanArray 179 | LongArray C.jlongArray 180 | IntArray C.jintArray 181 | Boolean C.jboolean 182 | Value uint64 // All JNI types fit into 64-bits. 183 | ) 184 | 185 | // Cached class handles. 186 | var classes struct { 187 | once sync.Once 188 | stringClass, integerClass Class 189 | 190 | integerIntValue MethodID 191 | } 192 | 193 | func env(e *Env) *C.JNIEnv { 194 | return (*C.JNIEnv)(unsafe.Pointer(e)) 195 | } 196 | 197 | func javavm(vm *JVM) *C.JavaVM { 198 | return (*C.JavaVM)(unsafe.Pointer(vm)) 199 | } 200 | 201 | // Do invokes a function with a temporary JVM environment. The 202 | // environment is not valid after the function returns. 203 | func Do(vm *JVM, f func(env *Env) error) error { 204 | runtime.LockOSThread() 205 | defer runtime.UnlockOSThread() 206 | var env *C.JNIEnv 207 | if res := C.jni_GetEnv(javavm(vm), &env, C.JNI_VERSION_1_6); res != C.JNI_OK { 208 | if res != C.JNI_EDETACHED { 209 | panic(fmt.Errorf("JNI GetEnv failed with error %d", res)) 210 | } 211 | if C.jni_AttachCurrentThread(javavm(vm), &env, nil) != C.JNI_OK { 212 | panic(errors.New("runInJVM: AttachCurrentThread failed")) 213 | } 214 | defer C.jni_DetachCurrentThread(javavm(vm)) 215 | } 216 | 217 | return f((*Env)(unsafe.Pointer(env))) 218 | } 219 | 220 | func Bool(b bool) Boolean { 221 | if b { 222 | return C.JNI_TRUE 223 | } 224 | return C.JNI_FALSE 225 | } 226 | 227 | func varArgs(args []Value) *C.jvalue { 228 | if len(args) == 0 { 229 | return nil 230 | } 231 | return (*C.jvalue)(unsafe.Pointer(&args[0])) 232 | } 233 | 234 | func IsSameObject(e *Env, ref1, ref2 Object) bool { 235 | same := C.jni_IsSameObject(env(e), C.jobject(ref1), C.jobject(ref2)) 236 | return same == C.JNI_TRUE 237 | } 238 | 239 | func CallStaticIntMethod(e *Env, cls Class, method MethodID, args ...Value) (int, error) { 240 | res := C.jni_CallStaticIntMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args)) 241 | return int(res), exception(e) 242 | } 243 | 244 | func CallStaticVoidMethod(e *Env, cls Class, method MethodID, args ...Value) error { 245 | C.jni_CallStaticVoidMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args)) 246 | return exception(e) 247 | } 248 | 249 | func CallVoidMethod(e *Env, obj Object, method MethodID, args ...Value) error { 250 | C.jni_CallVoidMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) 251 | return exception(e) 252 | } 253 | 254 | func CallStaticObjectMethod(e *Env, cls Class, method MethodID, args ...Value) (Object, error) { 255 | res := C.jni_CallStaticObjectMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args)) 256 | return Object(res), exception(e) 257 | } 258 | 259 | func CallObjectMethod(e *Env, obj Object, method MethodID, args ...Value) (Object, error) { 260 | res := C.jni_CallObjectMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) 261 | return Object(res), exception(e) 262 | } 263 | 264 | func CallBooleanMethod(e *Env, obj Object, method MethodID, args ...Value) (bool, error) { 265 | res := C.jni_CallBooleanMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) 266 | return res == C.JNI_TRUE, exception(e) 267 | } 268 | 269 | func CallIntMethod(e *Env, obj Object, method MethodID, args ...Value) (int32, error) { 270 | res := C.jni_CallIntMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args)) 271 | return int32(res), exception(e) 272 | } 273 | 274 | // GetByteArrayElements returns the contents of the byte array. 275 | func GetByteArrayElements(e *Env, jarr ByteArray) []byte { 276 | if jarr == 0 { 277 | return nil 278 | } 279 | size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) 280 | elems := C.jni_GetByteArrayElements(env(e), C.jbyteArray(jarr)) 281 | defer C.jni_ReleaseByteArrayElements(env(e), C.jbyteArray(jarr), elems, 0) 282 | backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:size:size] 283 | s := make([]byte, len(backing)) 284 | copy(s, backing) 285 | return s 286 | } 287 | 288 | // GetBooleanArrayElements returns the contents of the boolean array. 289 | func GetBooleanArrayElements(e *Env, jarr BooleanArray) []bool { 290 | if jarr == 0 { 291 | return nil 292 | } 293 | size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) 294 | elems := C.jni_GetBooleanArrayElements(env(e), C.jbooleanArray(jarr)) 295 | defer C.jni_ReleaseBooleanArrayElements(env(e), C.jbooleanArray(jarr), elems, 0) 296 | backing := (*(*[1 << 30]C.jboolean)(unsafe.Pointer(elems)))[:size:size] 297 | r := make([]bool, len(backing)) 298 | for i, b := range backing { 299 | r[i] = b == C.JNI_TRUE 300 | } 301 | return r 302 | } 303 | 304 | // GetStringArrayElements returns the contents of the String array. 305 | func GetStringArrayElements(e *Env, jarr ObjectArray) []string { 306 | var strings []string 307 | iterateObjectArray(e, jarr, func(e *Env, idx int, item Object) { 308 | s := GoString(e, String(item)) 309 | strings = append(strings, s) 310 | }) 311 | return strings 312 | } 313 | 314 | // GetIntArrayElements returns the contents of the int array. 315 | func GetIntArrayElements(e *Env, jarr IntArray) []int { 316 | if jarr == 0 { 317 | return nil 318 | } 319 | size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) 320 | elems := C.jni_GetIntArrayElements(env(e), C.jintArray(jarr)) 321 | defer C.jni_ReleaseIntArrayElements(env(e), C.jintArray(jarr), elems, 0) 322 | backing := (*(*[1 << 27]C.jint)(unsafe.Pointer(elems)))[:size:size] 323 | r := make([]int, len(backing)) 324 | for i, l := range backing { 325 | r[i] = int(l) 326 | } 327 | return r 328 | } 329 | 330 | // GetLongArrayElements returns the contents of the long array. 331 | func GetLongArrayElements(e *Env, jarr LongArray) []int64 { 332 | if jarr == 0 { 333 | return nil 334 | } 335 | size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) 336 | elems := C.jni_GetLongArrayElements(env(e), C.jlongArray(jarr)) 337 | defer C.jni_ReleaseLongArrayElements(env(e), C.jlongArray(jarr), elems, 0) 338 | backing := (*(*[1 << 27]C.jlong)(unsafe.Pointer(elems)))[:size:size] 339 | r := make([]int64, len(backing)) 340 | for i, l := range backing { 341 | r[i] = int64(l) 342 | } 343 | return r 344 | } 345 | 346 | func iterateObjectArray(e *Env, jarr ObjectArray, f func(e *Env, idx int, item Object)) { 347 | if jarr == 0 { 348 | return 349 | } 350 | size := C.jni_GetArrayLength(env(e), C.jarray(jarr)) 351 | for i := 0; i < int(size); i++ { 352 | item := C.jni_GetObjectArrayElement(env(e), C.jobjectArray(jarr), C.jint(i)) 353 | f(e, i, Object(item)) 354 | C.jni_DeleteLocalRef(env(e), item) 355 | } 356 | } 357 | 358 | // NewByteArray allocates a Java byte array with the content. It 359 | // panics if the allocation fails. 360 | func NewByteArray(e *Env, content []byte) ByteArray { 361 | jarr := C.jni_NewByteArray(env(e), C.jsize(len(content))) 362 | if jarr == 0 { 363 | panic(fmt.Errorf("jni: NewByteArray(%d) failed", len(content))) 364 | } 365 | elems := C.jni_GetByteArrayElements(env(e), jarr) 366 | defer C.jni_ReleaseByteArrayElements(env(e), jarr, elems, 0) 367 | backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:len(content):len(content)] 368 | copy(backing, content) 369 | return ByteArray(jarr) 370 | } 371 | 372 | // ClassLoader returns a reference to the Java ClassLoader associated 373 | // with obj. 374 | func ClassLoaderFor(e *Env, obj Object) Object { 375 | cls := GetObjectClass(e, obj) 376 | getClassLoader := GetMethodID(e, cls, "getClassLoader", "()Ljava/lang/ClassLoader;") 377 | clsLoader, err := CallObjectMethod(e, Object(obj), getClassLoader) 378 | if err != nil { 379 | // Class.getClassLoader should never fail. 380 | panic(err) 381 | } 382 | return Object(clsLoader) 383 | } 384 | 385 | // LoadClass invokes the underlying ClassLoader's loadClass method and 386 | // returns the class. 387 | func LoadClass(e *Env, loader Object, class string) (Class, error) { 388 | cls := GetObjectClass(e, loader) 389 | loadClass := GetMethodID(e, cls, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;") 390 | name := JavaString(e, class) 391 | loaded, err := CallObjectMethod(e, loader, loadClass, Value(name)) 392 | if err != nil { 393 | return 0, err 394 | } 395 | return Class(loaded), exception(e) 396 | } 397 | 398 | // exception returns an error corresponding to the pending 399 | // exception, and clears it. exceptionError returns nil if no 400 | // exception is pending. 401 | func exception(e *Env) error { 402 | thr := C.jni_ExceptionOccurred(env(e)) 403 | if thr == 0 { 404 | return nil 405 | } 406 | C.jni_ExceptionClear(env(e)) 407 | cls := GetObjectClass(e, Object(thr)) 408 | toString := GetMethodID(e, cls, "toString", "()Ljava/lang/String;") 409 | msg, err := CallObjectMethod(e, Object(thr), toString) 410 | if err != nil { 411 | return err 412 | } 413 | return errors.New(GoString(e, String(msg))) 414 | } 415 | 416 | // GetObjectClass returns the Java Class for an Object. 417 | func GetObjectClass(e *Env, obj Object) Class { 418 | if obj == 0 { 419 | panic("null object") 420 | } 421 | cls := C.jni_GetObjectClass(env(e), C.jobject(obj)) 422 | if err := exception(e); err != nil { 423 | // GetObjectClass should never fail. 424 | panic(err) 425 | } 426 | return Class(cls) 427 | } 428 | 429 | // GetStaticMethodID returns the id for a static method. It panics if the method 430 | // wasn't found. 431 | func GetStaticMethodID(e *Env, cls Class, name, signature string) MethodID { 432 | mname := C.CString(name) 433 | defer C.free(unsafe.Pointer(mname)) 434 | msig := C.CString(signature) 435 | defer C.free(unsafe.Pointer(msig)) 436 | m := C.jni_GetStaticMethodID(env(e), C.jclass(cls), mname, msig) 437 | if err := exception(e); err != nil { 438 | panic(err) 439 | } 440 | return MethodID(m) 441 | } 442 | 443 | // GetMethodID returns the id for a method. It panics if the method 444 | // wasn't found. 445 | func GetMethodID(e *Env, cls Class, name, signature string) MethodID { 446 | mname := C.CString(name) 447 | defer C.free(unsafe.Pointer(mname)) 448 | msig := C.CString(signature) 449 | defer C.free(unsafe.Pointer(msig)) 450 | m := C.jni_GetMethodID(env(e), C.jclass(cls), mname, msig) 451 | if err := exception(e); err != nil { 452 | panic(err) 453 | } 454 | return MethodID(m) 455 | } 456 | 457 | func NewGlobalRef(e *Env, obj Object) Object { 458 | return Object(C.jni_NewGlobalRef(env(e), C.jobject(obj))) 459 | } 460 | 461 | func DeleteGlobalRef(e *Env, obj Object) { 462 | C.jni_DeleteGlobalRef(env(e), C.jobject(obj)) 463 | } 464 | 465 | // JavaString converts the string to a JVM jstring. 466 | func JavaString(e *Env, str string) String { 467 | if str == "" { 468 | return 0 469 | } 470 | utf16Chars := utf16.Encode([]rune(str)) 471 | res := C.jni_NewString(env(e), (*C.jchar)(unsafe.Pointer(&utf16Chars[0])), C.int(len(utf16Chars))) 472 | return String(res) 473 | } 474 | 475 | // GoString converts the JVM jstring to a Go string. 476 | func GoString(e *Env, str String) string { 477 | if str == 0 { 478 | return "" 479 | } 480 | strlen := C.jni_GetStringLength(env(e), C.jstring(str)) 481 | chars := C.jni_GetStringChars(env(e), C.jstring(str)) 482 | var utf16Chars []uint16 483 | hdr := (*reflect.SliceHeader)(unsafe.Pointer(&utf16Chars)) 484 | hdr.Data = uintptr(unsafe.Pointer(chars)) 485 | hdr.Cap = int(strlen) 486 | hdr.Len = int(strlen) 487 | utf8 := utf16.Decode(utf16Chars) 488 | return string(utf8) 489 | } 490 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Tailscale is a mesh VPN alternative that makes it easy to connect your devices, wherever they are. No more fighting configuration or firewall ports. Built on WireGuard®, Tailscale enables an incremental shift to zero-trust networking by implementing “always-on” remote access. This guarantees a consistent, portable, and secure experience independent of physical location. 2 | 3 | WireGuard is a registered trademark of Jason A. Donenfeld. 4 | -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/metadata/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/metadata/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FZR-forks/tailscale-android/6030dd3fb5ef6f624c8365abca7a9b2689506b7b/metadata/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Mesh VPN based on WireGuard 2 | -------------------------------------------------------------------------------- /version/tailscale-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 4 | # Use of this source code is governed by a BSD-style 5 | # license that can be found in the LICENSE file. 6 | 7 | # Print the version tailscale repository corresponding 8 | # to the version listed in go.mod. 9 | 10 | set -euo pipefail 11 | 12 | go_list=$(go list -m tailscale.com) 13 | # go list outputs `tailscale.com `. Extract the version. 14 | mod_version=${go_list##* } 15 | 16 | if [ -z "$mod_version" ]; then 17 | echo "no version reported by go list -m tailscale.com: $go_list" 18 | exit 1 19 | fi 20 | 21 | case "$mod_version" in 22 | *-*-*) 23 | # A pseudo-version such as "v1.1.1-0.20201030135043-eab6e9ea4e45" 24 | # includes the commit hash. 25 | mod_version=${mod_version##*-*-} 26 | ;; 27 | esac 28 | 29 | tailscale_clone=$(mktemp -d -t tailscale-clone-XXXXXXXXXX) 30 | git clone -q https://github.com/tailscale/tailscale.git "$tailscale_clone" 31 | 32 | cd $tailscale_clone 33 | git reset --hard -q 34 | git clean -d -x -f 35 | git fetch -q --all --tags 36 | git checkout -q "$mod_version" 37 | 38 | eval $(./build_dist.sh shellvars) 39 | git_hash=$(git rev-parse HEAD) 40 | short_hash=$(echo "$git_hash" | cut -c1-9) 41 | echo ${VERSION_SHORT}-t${short_hash} 42 | cd /tmp 43 | rm -rf "$tailscale_clone" 44 | --------------------------------------------------------------------------------