package service import ( "context" "fmt" "net/http" "time" "github.com/steveiliop56/ding" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger" "go.uber.org/dig" ) const tailscaleAPIBaseURL = "https://api.tailscale.com/api/v2" var ( tailscaleAPIDeviceList = func(tailnet string) string { return tailscaleAPIBaseURL + "/tailnet/" + tailnet + "/devices" } tailscaleAPIUserList = func(tailnet string) string { return tailscaleAPIBaseURL + "/tailnet/" + tailnet + "/users" } ) type tailscaleDevice struct { Addresses []string `json:"addresses"` User string `json:"user"` Name string `json:"name"` Tags []string `json:"tags"` } type tailscaleAPIDevices struct { Devices []tailscaleDevice `json:"devices"` } type tailscaleUser struct { DisplayName string `json:"displayName"` LoginName string `json:"loginName"` } type tailscaleAPIUsers struct { Users []tailscaleUser `json:"users"` } type TailscaleWhoisResponse struct { DisplayName string LoginName string NodeName string } type TailscaleService struct { config *model.Config log *logger.Logger client *http.Client ctx context.Context caches struct { devices *CacheStore[tailscaleAPIDevices] users *CacheStore[tailscaleAPIUsers] } urls struct { devices string users string } } type TailscaleServiceInput struct { dig.In Ding *ding.Ding Config *model.Config Log *logger.Logger Ctx context.Context } func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) { if !i.Config.Tailscale.Enabled { return nil, nil } if i.Config.Tailscale.APIToken == "" { return nil, fmt.Errorf("tailscale api token not set") } if i.Config.Tailscale.Tailnet == "" { return nil, fmt.Errorf("tailscale tailnet not set") } s := &TailscaleService{ config: i.Config, log: i.Log, ctx: i.Ctx, } devicesCache := NewCacheStore[tailscaleAPIDevices](0) usersCache := NewCacheStore[tailscaleAPIUsers](0) s.caches.devices = devicesCache s.caches.users = usersCache i.Ding.Go(func(ctx context.Context) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: s.caches.devices.Sweep() s.caches.users.Sweep() case <-ctx.Done(): return } } }, ding.RingMinor) s.urls.devices = tailscaleAPIDeviceList(i.Config.Tailscale.Tailnet) s.urls.users = tailscaleAPIUserList(i.Config.Tailscale.Tailnet) s.client = &http.Client{ Timeout: 10 * time.Second, } _, _, err := s.getDeviceList() if err != nil { return nil, fmt.Errorf("failed to get device list: %w", err) } _, _, err = s.getUsersList() if err != nil { return nil, fmt.Errorf("failed to get user list: %w", err) } s.log.App.Info().Msg("Tailscale service initialized") return s, nil } func (s *TailscaleService) buildAuthorizationHeader() string { return "Bearer " + s.config.Tailscale.APIToken } func (s *TailscaleService) getDeviceList() (*tailscaleAPIDevices, bool, error) { cached, ok := s.caches.devices.Get("devices") if ok { return &cached, true, nil } devices, err := simpleReq[tailscaleAPIDevices](s.client, s.ctx, s.urls.devices, map[string]string{ "Authorization": s.buildAuthorizationHeader(), }) if err != nil { return nil, false, fmt.Errorf("failed to get device list: %w", err) } s.caches.devices.Set("devices", *devices, time.Duration(s.config.Tailscale.CacheDuration)*time.Second) return devices, false, nil } func (s *TailscaleService) getUsersList() (*tailscaleAPIUsers, bool, error) { cached, ok := s.caches.users.Get("users") if ok { return &cached, true, nil } users, err := simpleReq[tailscaleAPIUsers](s.client, s.ctx, s.urls.users, map[string]string{ "Authorization": s.buildAuthorizationHeader(), }) if err != nil { return nil, false, fmt.Errorf("failed to get user list: %w", err) } s.caches.users.Set("users", *users, time.Duration(s.config.Tailscale.CacheDuration)*time.Second) return users, false, nil } func (s *TailscaleService) Whois(addr string) (*TailscaleWhoisResponse, error) { var device *tailscaleDevice devices, dCacheHit, err := s.getDeviceList() if err != nil { return nil, fmt.Errorf("failed to get device list: %w", err) } for _, d := range devices.Devices { if len(d.Tags) != 0 { continue } for _, a := range d.Addresses { if a == addr { device = &d break } } } if device == nil { return nil, nil } s.log.App.Debug().Str("device", device.Name).Bool("cache_hit", dCacheHit).Msg("Tailscale device found") var user *tailscaleUser users, uCacheHit, err := s.getUsersList() if err != nil { return nil, fmt.Errorf("failed to get user list: %w", err) } for _, u := range users.Users { if u.LoginName == device.User { user = &u break } } if user == nil { return nil, nil } s.log.App.Debug().Str("user", user.LoginName).Bool("cache_hit", uCacheHit).Msg("Tailscale user found") return &TailscaleWhoisResponse{ LoginName: user.LoginName, DisplayName: user.DisplayName, NodeName: device.Name, }, nil }