You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale-android/cmd/tailscale/main.go

527 lines
12 KiB
Go

// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"log"
"sort"
"strings"
"sync"
"time"
"gioui.org/app"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/font/gofont"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tailscale-android/jni"
"tailscale.com/wgengine/router"
)
//go:generate go run github.com/go-bindata/go-bindata/go-bindata -nocompress -o logo.go tailscale.png
type App struct {
jvm jni.JVM
appCtx jni.Object
appDir string
store *stateStore
// updates is notifies whenever netState or browseURL changes.
updates chan struct{}
// vpnClosed is notified when the VPNService is closed while
// logged in.
vpnClosed chan struct{}
// mu protects the following fields.
mu sync.Mutex
// netState is the most recent network state.
netState NetworkState
// browseURL is set whenever the backend wants to
// browse.
browseURL *string
}
type clientState struct {
browseURL string
net NetworkState
// query is the search query, in lowercase.
query string
Peers []UIPeer
// WantsEnabled is the desired state of the VPN: enabled or
// disabled.
WantsEnabled bool
}
type NetworkState struct {
State ipn.State
NetworkMap *controlclient.NetworkMap
HasInternet bool
}
// UIEvent is an event flowing from the UI to the backend.
type UIEvent interface{}
type ConnectEvent struct {
Enable bool
}
type CopyEvent struct {
Text string
}
type SearchEvent struct {
Query string
}
type ReauthEvent struct{}
type LogoutEvent struct{}
const enabledKey = "ipn_enabled"
func main() {
a := &App{
jvm: jni.JVMFor(app.JavaVM()),
appCtx: jni.Object(app.AppContext()),
updates: make(chan struct{}, 1),
vpnClosed: make(chan struct{}, 1),
}
appDir, err := app.DataDir()
if err != nil {
fatalErr(err)
}
a.appDir = appDir
a.store = newStateStore(a.appDir, a.jvm, a.appCtx)
events := make(chan UIEvent)
go func() {
if err := a.runBackend(events); err != nil {
fatalErr(err)
}
}()
go func() {
if err := a.runUI(events); err != nil {
fatalErr(err)
}
}()
app.Main()
}
func (a *App) runBackend(events <-chan UIEvent) error {
var cfg *router.Config
var state NetworkState
var service jni.Object
var b *backend
b, err := newBackend(a.appDir, a.jvm, a.store, func(s *router.Config) error {
cfg = s
if b == nil || service == 0 || cfg == nil {
return nil
}
return b.updateTUN(service, cfg)
})
if err != nil {
return err
}
defer b.CloseTUNs()
var timer *time.Timer
var alarmChan <-chan time.Time
alarm := func(t *time.Timer) {
if timer != nil {
timer.Stop()
}
timer = t
if timer != nil {
alarmChan = timer.C
}
}
err = b.Start(func(n ipn.Notify) {
if s := n.State; s != nil {
oldState := state.State
state.State = *s
if service != 0 {
a.updateNotification(service, state.State)
}
if service != 0 {
if cfg != nil && state.State >= ipn.Starting {
if err := b.updateTUN(service, cfg); err != nil {
a.notifyVPNClosed()
}
} else {
b.CloseTUNs()
}
}
// Stop VPN if we logged out.
if oldState > ipn.NeedsLogin && state.State <= ipn.NeedsLogin {
if err := a.callVoidMethod(a.appCtx, "stopVPN", "()V"); err != nil {
fatalErr(err)
}
}
a.notify(state)
}
if u := n.BrowseToURL; u != nil {
a.setURL(*u)
}
if m := n.NetMap; m != nil {
state.NetworkMap = m
a.notify(state)
if service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
}
}
})
if err != nil {
return err
}
for {
select {
case <-alarmChan:
if m := state.NetworkMap; m != nil && service != 0 {
alarm(a.notifyExpiry(service, m.Expiry))
}
case e := <-events:
switch e.(type) {
case ReauthEvent:
b.backend.StartLoginInteractive()
case LogoutEvent:
b.backend.Logout()
}
case s := <-onConnect:
jni.Do(a.jvm, func(env jni.Env) error {
if jni.IsSameObject(env, s, service) {
// We already have a reference.
jni.DeleteGlobalRef(env, s)
return nil
}
if service != 0 {
jni.DeleteGlobalRef(env, service)
}
service = s
return nil
})
a.updateNotification(service, state.State)
if m := state.NetworkMap; m != nil {
alarm(a.notifyExpiry(service, m.Expiry))
}
if cfg != nil && state.State >= ipn.Starting {
if err := b.updateTUN(service, cfg); err != nil {
a.notifyVPNClosed()
}
}
case <-onConnectivityChange:
state.HasInternet = connected.Load().(bool)
if b != nil {
b.LinkChange()
}
a.notify(state)
case s := <-onDisconnect:
b.CloseTUNs()
jni.Do(a.jvm, func(env jni.Env) error {
defer jni.DeleteGlobalRef(env, s)
if jni.IsSameObject(env, service, s) {
jni.DeleteGlobalRef(env, service)
service = 0
}
return nil
})
if state.State >= ipn.Starting {
a.notifyVPNClosed()
}
}
}
}
// updateNotification updates the foreground persistent status notification.
func (a *App) updateNotification(service jni.Object, state ipn.State) error {
var msg, title string
switch state {
case ipn.Starting:
title, msg = "Connecting...", ""
case ipn.Running:
title, msg = "Connected", ""
default:
return nil
}
return jni.Do(a.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, service)
update := jni.GetMethodID(env, cls, "updateStatusNotification", "(Ljava/lang/String;Ljava/lang/String;)V")
jtitle := jni.JavaString(env, title)
jmessage := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, service, update, jni.Value(jtitle), jni.Value(jmessage))
})
}
// notifyExpiry notifies the user of imminent session expiry and
// returns a new timer that triggers when the user should be notified
// again.
func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer {
d := time.Until(expiry)
var title string
const msg = "Reauthenticate to maintain the connection to your network."
var t *time.Timer
const (
aday = 24 * time.Hour
soon = 5 * time.Minute
)
switch {
case d <= 0:
title = "Your authentication has expired!"
case d <= soon:
title = "Your authentication expires soon!"
t = time.NewTimer(d)
case d <= aday:
title = "Your authentication expires in a day."
t = time.NewTimer(d - soon)
default:
return time.NewTimer(d - aday)
}
err := jni.Do(a.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, service)
notify := jni.GetMethodID(env, cls, "notify", "(Ljava/lang/String;Ljava/lang/String;)V")
jtitle := jni.JavaString(env, title)
jmessage := jni.JavaString(env, msg)
return jni.CallVoidMethod(env, service, notify, jni.Value(jtitle), jni.Value(jmessage))
})
if err != nil {
fatalErr(err)
}
return t
}
func (a *App) notifyVPNClosed() {
select {
case a.vpnClosed <- struct{}{}:
default:
}
}
func (a *App) notify(state NetworkState) {
a.mu.Lock()
a.netState = state
a.mu.Unlock()
select {
case a.updates <- struct{}{}:
default:
}
}
func (a *App) setURL(url string) {
a.mu.Lock()
a.browseURL = &url
a.mu.Unlock()
select {
case a.updates <- struct{}{}:
default:
}
}
func (a *App) runUI(backend chan<- UIEvent) error {
w := app.NewWindow()
gofont.Register()
ui, err := newUI(a.store)
if err != nil {
return err
}
// Register an Android Fragment instance for lifecycle tracking
// of our Activity.
w.RegisterFragment("com.tailscale.ipn.Peer")
var ops op.Ops
state := new(clientState)
var peer jni.Object
state.WantsEnabled, _ = a.store.ReadBool(enabledKey, true)
ui.enabled.Value = state.WantsEnabled
for {
select {
case <-a.vpnClosed:
state.WantsEnabled = false
w.Invalidate()
case <-a.updates:
a.mu.Lock()
oldState := state.net.State
state.net = a.netState
if a.browseURL != nil {
state.browseURL = *a.browseURL
a.browseURL = nil
}
a.mu.Unlock()
a.updateState(peer, state)
w.Invalidate()
if peer != 0 {
newState := state.net.State
// Start VPN if we just logged in.
if state.WantsEnabled && oldState <= ipn.NeedsLogin && newState > ipn.NeedsLogin {
if err := a.callVoidMethod(peer, "prepareVPN", "()V"); err != nil {
fatalErr(err)
}
}
}
case peer = <-onPeerCreated:
w.Invalidate()
a.setVPNState(peer, state)
case p := <-onPeerDestroyed:
jni.Do(a.jvm, func(env jni.Env) error {
defer jni.DeleteGlobalRef(env, p)
if jni.IsSameObject(env, peer, p) {
jni.DeleteGlobalRef(env, peer)
peer = 0
}
return nil
})
case <-vpnPrepared:
if err := a.callVoidMethod(a.appCtx, "startVPN", "()V"); err != nil {
return err
}
case e := <-w.Events():
switch e := e.(type) {
case system.DestroyEvent:
return e.Err
case system.FrameEvent:
gtx := layout.NewContext(&ops, e.Queue, e.Config, e.Size)
events := ui.layout(gtx, e.Insets, state)
e.Frame(gtx.Ops)
a.processUIEvents(backend, w, events, peer, state)
}
}
}
}
func (a *App) updateState(javaPeer jni.Object, state *clientState) {
if javaPeer != 0 && state.browseURL != "" {
a.browseToURL(javaPeer, state.browseURL)
state.browseURL = ""
}
state.Peers = nil
netMap := state.net.NetworkMap
if netMap == nil {
return
}
// Split into sections.
users := make(map[tailcfg.UserID]struct{})
var peers []UIPeer
for _, p := range netMap.Peers {
if q := state.query; q != "" {
// Filter peers according to search query.
host := strings.ToLower(p.Hostinfo.Hostname)
name := strings.ToLower(p.Name)
var addr string
if len(p.Addresses) > 0 {
addr = p.Addresses[0].IP.String()
}
if !strings.Contains(host, q) && !strings.Contains(name, q) && !strings.Contains(addr, q) {
continue
}
}
users[p.User] = struct{}{}
peers = append(peers, UIPeer{
Owner: p.User,
Peer: p,
})
}
// Add section (user) headers.
for u := range users {
name := netMap.UserProfiles[u].DisplayName
name = strings.ToUpper(name)
peers = append(peers, UIPeer{Owner: u, Name: name})
}
myID := state.net.NetworkMap.User
sort.Slice(peers, func(i, j int) bool {
lhs, rhs := peers[i], peers[j]
if lu, ru := lhs.Owner, rhs.Owner; ru != lu {
// Sort own peers first.
if lu == myID {
return true
}
if ru == myID {
return false
}
return lu < ru
}
lp, rp := lhs.Peer, rhs.Peer
// Sort headers first.
if lp == nil {
return true
}
if rp == nil {
return false
}
return lp.Hostinfo.Hostname < rp.Hostinfo.Hostname ||
lp.Hostinfo.Hostname == rp.Hostinfo.Hostname && lp.ID < rp.ID
})
state.Peers = peers
}
func (a *App) processUIEvents(backend chan<- UIEvent, w *app.Window, events []UIEvent, peer jni.Object, state *clientState) {
for _, e := range events {
switch e := e.(type) {
case ReauthEvent:
go func() {
backend <- e
}()
case LogoutEvent:
go func() {
backend <- e
}()
case CopyEvent:
w.WriteClipboard(e.Text)
case SearchEvent:
state.query = strings.ToLower(e.Query)
a.updateState(peer, state)
case ConnectEvent:
if e.Enable == state.WantsEnabled {
return
}
if e.Enable && peer == 0 {
return
}
state.WantsEnabled = e.Enable
a.store.WriteBool(enabledKey, e.Enable)
a.updateState(peer, state)
a.setVPNState(peer, state)
}
}
}
func (a *App) setVPNState(peer jni.Object, state *clientState) {
var err error
if state.WantsEnabled && state.net.State > ipn.NeedsLogin {
err = a.callVoidMethod(peer, "prepareVPN", "()V")
} else {
err = a.callVoidMethod(a.appCtx, "stopVPN", "()V")
}
if err != nil {
fatalErr(err)
}
}
func (a *App) browseToURL(peer jni.Object, url string) {
err := jni.Do(a.jvm, func(env jni.Env) error {
jurl := jni.JavaString(env, url)
return a.callVoidMethod(peer, "showURLCustomTabs", "(Ljava/lang/String;)V", jni.Value(jurl))
})
if err != nil {
fatalErr(err)
}
}
func (a *App) callVoidMethod(obj jni.Object, name, sig string, args ...jni.Value) error {
if obj == 0 {
panic("invalid object")
}
return jni.Do(a.jvm, func(env jni.Env) error {
cls := jni.GetObjectClass(env, obj)
m := jni.GetMethodID(env, cls, name, sig)
return jni.CallVoidMethod(env, obj, m, args...)
})
}
func fatalErr(err error) {
log.Print(err)
}