diff options
| author | Anton Tolchanov <anton@tailscale.com> | 2024-01-12 16:00:07 -0500 |
|---|---|---|
| committer | Anton Tolchanov <anton@tailscale.com> | 2024-01-12 16:06:02 -0500 |
| commit | 7a03650a9e4c8b5321caebaaa812ca8066936a7f (patch) | |
| tree | 1ab619bb86100f1c64e2ca993f39c4d3801e0219 | |
| parent | 6540d1f01800d03fa46d467fae777163b8ba3d0d (diff) | |
| download | tailscale-knyar/worklifeposture.tar.xz tailscale-knyar/worklifeposture.zip | |
work life postureknyar/worklifeposture
| -rw-r--r-- | cmd/worklifeposture/.gitignore | 1 | ||||
| -rw-r--r-- | cmd/worklifeposture/worklifeposture.go | 168 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | wgengine/magicsock/endpoint.go | 11 |
5 files changed, 186 insertions, 0 deletions
diff --git a/cmd/worklifeposture/.gitignore b/cmd/worklifeposture/.gitignore new file mode 100644 index 000000000..cc6618c2d --- /dev/null +++ b/cmd/worklifeposture/.gitignore @@ -0,0 +1 @@ +GeoLite2-City.mmdb diff --git a/cmd/worklifeposture/worklifeposture.go b/cmd/worklifeposture/worklifeposture.go new file mode 100644 index 000000000..10bd211cf --- /dev/null +++ b/cmd/worklifeposture/worklifeposture.go @@ -0,0 +1,168 @@ +// worklifeposture enables achieving work-life balance through device posture. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/oschwald/geoip2-golang" + "golang.org/x/oauth2/clientcredentials" + "tailscale.com/ipn/ipnstate" + "tailscale.com/syncs" + "tailscale.com/tailcfg" + "tailscale.com/tsnet" + "tailscale.com/types/logger" + "tailscale.com/util/httpm" +) + +func main() { + s := &tsnet.Server{ + Hostname: "worklifeposture", + Logf: logger.Discard, + } + + // maxmind.com + db, err := geoip2.Open("GeoLite2-City.mmdb") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + writer := newAttrWriter() + + var lastProcessed syncs.Map[tailcfg.StableNodeID, time.Time] + for { + lc, err := s.LocalClient() + if err != nil { + log.Printf("error getting local client: %s", err) + continue + } + st, err := lc.Status(context.Background()) + if err != nil { + log.Printf("error calling status: %s", err) + continue + } + + var wg sync.WaitGroup + sema := make(chan struct{}, 5) // limit concurrency + for _, peer := range st.Peer { + if last, ok := lastProcessed.Load(peer.ID); ok && time.Since(last) < 5*time.Minute { + continue + } + sema <- struct{}{} // acquire a semaphore + wg.Add(1) + go func(peer *ipnstate.PeerStatus) { + defer wg.Done() + defer func() { <-sema }() // release the semaphore + + // Ping to trigger disco. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _, err := lc.Ping(ctx, peer.TailscaleIPs[0], tailcfg.PingPeerAPI) + cancel() + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) { + log.Printf("ping %s error: %s", peer.ID, err) + } + return + } + + // Look up timezone based on the public IP. + ip := peer.CurAddr + if ip == "" { + log.Printf("no IP for peer %s", peer.ID) + return + } + netip := net.ParseIP(ip) + if netip == nil { + log.Printf("cannot parse IP %q for peer %s", ip, peer.ID) + return + } + res, err := db.City(netip) + if err != nil { + log.Printf("error looking up details for %s: %s", netip, err) + return + } + + // Write an attribute depending on whether it's working hours in a given timezone. + tz, err := time.LoadLocation(res.Location.TimeZone) + if err != nil { + log.Printf("error loading location %s: %s", res.Location.TimeZone, err) + return + } + at := time.Now().In(tz) + value := "life" + if shouldYouWork(at) { + value = "work" + } + if err := writer.write(peer.ID, "custom:balance", value); err != nil { + log.Printf("error writing attribute for %s: %s", peer.ID, err) + return + } + lastProcessed.Store(peer.ID, time.Now()) + }(peer) + } + wg.Wait() + time.Sleep(5 * time.Second) + } +} + +func shouldYouWork(at time.Time) bool { + if at.Weekday() == time.Saturday || at.Weekday() == time.Sunday { + return false + } + workingBegins := time.Date(at.Year(), at.Month(), at.Day(), 9, 0, 0, 0, at.Location()) + workingFinallyEnds := time.Date(at.Year(), at.Month(), at.Day(), 17, 0, 0, 0, at.Location()) + if at.After(workingBegins) && at.Before(workingFinallyEnds) { + return true + } + return false +} + +type attrWriter struct { + client *http.Client +} + +func newAttrWriter() *attrWriter { + var oauthConfig = &clientcredentials.Config{ + ClientID: os.Getenv("OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"), + TokenURL: "https://api.tailscale.com/api/v2/oauth/token", + } + return &attrWriter{client: oauthConfig.Client(context.Background())} +} + +func (aw *attrWriter) write(nodeID tailcfg.StableNodeID, key, value string) error { + url := fmt.Sprintf("https://api.tailscale.com/api/v2/device/%s/attributes/%s", nodeID, key) + + valueMap := map[string]any{"value": value} + valueBody, err := json.Marshal(valueMap) + if err != nil { + return err + } + + setReq, err := http.NewRequest(httpm.POST, url, bytes.NewReader(valueBody)) + if err != nil { + return err + } + + resp, err := aw.client.Do(setReq) + if err != nil { + return err + } + resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status for %s: %s", nodeID, resp.Status) + } + log.Printf("set %s %q=%q", nodeID, key, value) + return nil +} @@ -53,6 +53,7 @@ require ( github.com/mdlayher/sdnotify v1.0.0 github.com/miekg/dns v1.1.56 github.com/mitchellh/go-ps v1.0.0 + github.com/oschwald/geoip2-golang v1.9.0 github.com/peterbourgon/ff/v3 v3.4.0 github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.6 @@ -115,6 +116,7 @@ require ( github.com/gobuffalo/flect v1.0.2 // indirect github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect github.com/gorilla/securecookie v1.1.1 // indirect + github.com/oschwald/maxminddb-golang v1.11.0 // indirect ) require ( @@ -722,6 +722,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= +github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= +github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0= +github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg= github.com/otiai10/copy v1.2.0 h1:HvG945u96iNadPoG2/Ja2+AUJeW5YuFQMixq9yirC+k= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= diff --git a/wgengine/magicsock/endpoint.go b/wgengine/magicsock/endpoint.go index 58267267d..50b176688 100644 --- a/wgengine/magicsock/endpoint.go +++ b/wgengine/magicsock/endpoint.go @@ -1338,6 +1338,17 @@ func (de *endpoint) populatePeerStatus(ps *ipnstate.PeerStatus) { if udpAddr, derpAddr, _ := de.addrForSendLocked(now); udpAddr.IsValid() && !derpAddr.IsValid() { ps.CurAddr = udpAddr.String() } + + // If we don't have a direct connection, look for a public IP in the list of advertised endpoints. + if ps.CurAddr != "" { + return + } + for ip := range de.endpointState { + if ip.Addr().IsPrivate() { + continue + } + ps.CurAddr = ip.Addr().String() + } } // stopAndReset stops timers associated with de and resets its state back to zero. |
