├── .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 | [
](https://f-droid.org/packages/com.tailscale.ipn/)
16 | [
](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 |
--------------------------------------------------------------------------------