From 8cf2805ccad7cfa685030dc98475badf4e98eb88 Mon Sep 17 00:00:00 2001 From: David Crawshaw Date: Sun, 29 Jan 2023 14:04:40 -0800 Subject: [PATCH] tailcfg, localapi: plumb device token to server Updates tailscale/corp#8940 Signed-off-by: David Crawshaw --- client/tailscale/apitype/apitype.go | 6 ++++ hostinfo/hostinfo.go | 18 ++++++++--- ipn/ipnlocal/local.go | 15 +++++++++ ipn/localapi/localapi.go | 20 ++++++++++++ ipn/localapi/localapi_test.go | 47 ++++++++++++++++++++++++++++ tailcfg/tailcfg.go | 1 + tailcfg/tailcfg_clone.go | 1 + tailcfg/tailcfg_test.go | 1 + tailcfg/tailcfg_view.go | 48 +++++++++++++++-------------- 9 files changed, 130 insertions(+), 27 deletions(-) diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index fbb8a5b43..e4c4e538f 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -32,3 +32,9 @@ type WaitingFile struct { Name string Size int64 } + +// SetPushDeviceTokenRequest is the body POSTed to the LocalAPI endpoint /set-device-token. +type SetPushDeviceTokenRequest struct { + // PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent). + PushDeviceToken string +} diff --git a/hostinfo/hostinfo.go b/hostinfo/hostinfo.go index 8dc53fa49..1920e2de8 100644 --- a/hostinfo/hostinfo.go +++ b/hostinfo/hostinfo.go @@ -50,6 +50,7 @@ func New() *tailcfg.Hostinfo { GoVersion: runtime.Version(), Machine: condCall(unameMachine), DeviceModel: deviceModel(), + PushDeviceToken: pushDeviceToken(), Cloud: string(cloudenv.Get()), NoLogsNoSupport: envknob.NoLogsNoSupport(), AllowsUpdate: envknob.AllowsRemoteUpdate(), @@ -153,12 +154,16 @@ func GetEnvType() EnvType { } var ( - deviceModelAtomic atomic.Value // of string - osVersionAtomic atomic.Value // of string - desktopAtomic atomic.Value // of opt.Bool - packagingType atomic.Value // of string + pushDeviceTokenAtomic atomic.Value // of string + deviceModelAtomic atomic.Value // of string + osVersionAtomic atomic.Value // of string + desktopAtomic atomic.Value // of opt.Bool + packagingType atomic.Value // of string ) +// SetPushDeviceToken sets the device token for use in Hostinfo updates. +func SetPushDeviceToken(token string) { pushDeviceTokenAtomic.Store(token) } + // SetDeviceModel sets the device model for use in Hostinfo updates. func SetDeviceModel(model string) { deviceModelAtomic.Store(model) } @@ -176,6 +181,11 @@ func deviceModel() string { return s } +func pushDeviceToken() string { + s, _ := pushDeviceTokenAtomic.Load().(string) + return s +} + func desktop() (ret opt.Bool) { if runtime.GOOS != "linux" { return opt.Bool("") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index e0e9e18d7..54fd9d348 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1811,6 +1811,21 @@ func (b *LocalBackend) readPoller() { } } +// ResendHostinfoIfNeeded is called to recompute the Hostinfo and send +// the new version to the control server. +func (b *LocalBackend) ResendHostinfoIfNeeded() { + hi := hostinfo.New() + + b.mu.Lock() + if b.hostinfo != nil { + hi.Services = b.hostinfo.Services + } + b.hostinfo = hi + b.mu.Unlock() + + b.doSetHostinfoFilterServices(hi) +} + // WatchNotifications subscribes to the ipn.Notify message bus notification // messages. // diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index debd908dd..e84c637b1 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -70,6 +70,7 @@ var handler = map[string]localAPIHandler{ "debug-packet-filter-rules": (*Handler).serveDebugPacketFilterRules, "derpmap": (*Handler).serveDERPMap, "dev-set-state-store": (*Handler).serveDevSetStateStore, + "set-push-device-token": (*Handler).serveSetPushDeviceToken, "dial": (*Handler).serveDial, "file-targets": (*Handler).serveFileTargets, "goroutines": (*Handler).serveGoroutines, @@ -1205,6 +1206,25 @@ func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) { <-errc } +func (h *Handler) serveSetPushDeviceToken(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "set push device token access denied", http.StatusForbidden) + return + } + if r.Method != "POST" { + http.Error(w, "unsupported method", http.StatusMethodNotAllowed) + return + } + var params apitype.SetPushDeviceTokenRequest + if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { + http.Error(w, "invalid JSON body", 400) + return + } + hostinfo.SetPushDeviceToken(params.PushDeviceToken) + h.b.ResendHostinfoIfNeeded() + w.WriteHeader(http.StatusOK) +} + func (h *Handler) serveUploadClientMetrics(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "unsupported method", http.StatusMethodNotAllowed) diff --git a/ipn/localapi/localapi_test.go b/ipn/localapi/localapi_test.go index 6f6fe48ce..1fdc3874d 100644 --- a/ipn/localapi/localapi_test.go +++ b/ipn/localapi/localapi_test.go @@ -4,9 +4,16 @@ package localapi import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" "tailscale.com/client/tailscale/apitype" + "tailscale.com/hostinfo" + "tailscale.com/ipn/ipnlocal" ) func TestValidHost(t *testing.T) { @@ -32,3 +39,43 @@ func TestValidHost(t *testing.T) { }) } } + +func TestSetPushDeviceToken(t *testing.T) { + origValidLocalHost := validLocalHost + validLocalHost = true + defer func() { + validLocalHost = origValidLocalHost + }() + + h := &Handler{ + PermitWrite: true, + b: &ipnlocal.LocalBackend{}, + } + s := httptest.NewServer(h) + defer s.Close() + c := s.Client() + + want := "my-test-device-token" + body, err := json.Marshal(apitype.SetPushDeviceTokenRequest{PushDeviceToken: want}) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest("POST", s.URL+"/localapi/v0/set-push-device-token", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + body, err = io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != 200 { + t.Errorf("res.StatusCode=%d, want 200. body: %s", res.StatusCode, body) + } + if got := hostinfo.New().PushDeviceToken; got != want { + t.Errorf("hostinfo.PushDeviceToken=%q, want %q", got, want) + } +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index e29ebca73..95dc79404 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -525,6 +525,7 @@ type Hostinfo struct { Desktop opt.Bool `json:",omitempty"` // if a desktop was detected on Linux Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown) DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3") + PushDeviceToken string `json:",omitempty"` // macOS/iOS APNs device token for notifications (and Android in the future) Hostname string `json:",omitempty"` // name of the host the client runs on ShieldsUp bool `json:",omitempty"` // indicates whether the host is blocking incoming connections ShareeNode bool `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 689b57771..b1bef1d68 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -131,6 +131,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct { Desktop opt.Bool Package string DeviceModel string + PushDeviceToken string Hostname string ShieldsUp bool ShareeNode bool diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index 34cba3e79..a07557125 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -44,6 +44,7 @@ func TestHostinfoEqual(t *testing.T) { "Desktop", "Package", "DeviceModel", + "PushDeviceToken", "Hostname", "ShieldsUp", "ShareeNode", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 463cc3620..dfd48f2bc 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -257,29 +257,30 @@ func (v *HostinfoView) UnmarshalJSON(b []byte) error { return nil } -func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion } -func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID } -func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID } -func (v HostinfoView) OS() string { return v.ж.OS } -func (v HostinfoView) OSVersion() string { return v.ж.OSVersion } -func (v HostinfoView) Container() opt.Bool { return v.ж.Container } -func (v HostinfoView) Env() string { return v.ж.Env } -func (v HostinfoView) Distro() string { return v.ж.Distro } -func (v HostinfoView) DistroVersion() string { return v.ж.DistroVersion } -func (v HostinfoView) DistroCodeName() string { return v.ж.DistroCodeName } -func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop } -func (v HostinfoView) Package() string { return v.ж.Package } -func (v HostinfoView) DeviceModel() string { return v.ж.DeviceModel } -func (v HostinfoView) Hostname() string { return v.ж.Hostname } -func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp } -func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode } -func (v HostinfoView) NoLogsNoSupport() bool { return v.ж.NoLogsNoSupport } -func (v HostinfoView) WireIngress() bool { return v.ж.WireIngress } -func (v HostinfoView) AllowsUpdate() bool { return v.ж.AllowsUpdate } -func (v HostinfoView) Machine() string { return v.ж.Machine } -func (v HostinfoView) GoArch() string { return v.ж.GoArch } -func (v HostinfoView) GoArchVar() string { return v.ж.GoArchVar } -func (v HostinfoView) GoVersion() string { return v.ж.GoVersion } +func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion } +func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID } +func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID } +func (v HostinfoView) OS() string { return v.ж.OS } +func (v HostinfoView) OSVersion() string { return v.ж.OSVersion } +func (v HostinfoView) Container() opt.Bool { return v.ж.Container } +func (v HostinfoView) Env() string { return v.ж.Env } +func (v HostinfoView) Distro() string { return v.ж.Distro } +func (v HostinfoView) DistroVersion() string { return v.ж.DistroVersion } +func (v HostinfoView) DistroCodeName() string { return v.ж.DistroCodeName } +func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop } +func (v HostinfoView) Package() string { return v.ж.Package } +func (v HostinfoView) DeviceModel() string { return v.ж.DeviceModel } +func (v HostinfoView) PushDeviceToken() string { return v.ж.PushDeviceToken } +func (v HostinfoView) Hostname() string { return v.ж.Hostname } +func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp } +func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode } +func (v HostinfoView) NoLogsNoSupport() bool { return v.ж.NoLogsNoSupport } +func (v HostinfoView) WireIngress() bool { return v.ж.WireIngress } +func (v HostinfoView) AllowsUpdate() bool { return v.ж.AllowsUpdate } +func (v HostinfoView) Machine() string { return v.ж.Machine } +func (v HostinfoView) GoArch() string { return v.ж.GoArch } +func (v HostinfoView) GoArchVar() string { return v.ж.GoArchVar } +func (v HostinfoView) GoVersion() string { return v.ж.GoVersion } func (v HostinfoView) RoutableIPs() views.IPPrefixSlice { return views.IPPrefixSliceOf(v.ж.RoutableIPs) } @@ -307,6 +308,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct { Desktop opt.Bool Package string DeviceModel string + PushDeviceToken string Hostname string ShieldsUp bool ShareeNode bool