Files
tailscale/feature/serviceclientprefs/serviceclientprefs.go
T
Rollie MaandGitHub f4978b4b9b feature,client: add serviceclientprefs for desktop client service launch (#20501)
Add serviceclientprefs, an optional feature that stores and loads the
desktop clients' saved service launch preferences, one file per login
profile.

- Add GET|POST /localapi/v0/prefs/service-clients to load and save the
  current profile's service client prefs.
- Add local client GetServiceClientPrefs and SetServiceClientPref that
  call the new local api endpoint.
- Store the prefs with the ipn/store FileStore at
  TailscaleVarRoot()/profile-data/<profileID>/service-client-prefs/<hex-encoded-key>,
  so DeleteProfile cleans them up for free. Fall back to an in-memory
  store when there's no var root.
- Register the feature and its local api route from build tagged files
  so the whole thing drops out under ts_omit_serviceclientprefs.
- Add the serviceclient package holding Pref and Prefs (saved client,
  username, database name, and last used time), so the local api client
  and desktop apps can import the types without the feature machinery.

Change-Id: I340a99c1b332d181fb1556fbf3e8003bb3b95a08
Updates: https://github.com/tailscale/tailscale/issues/20429

Signed-off-by: Rollie Ma <rollie@tailscale.com>
2026-07-22 20:36:05 -07:00

103 lines
3.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package serviceclientprefs stores the desktop clients' saved service launch preferences, in a
// file per login profile.
package serviceclientprefs
import (
"encoding/hex"
"path/filepath"
"sync"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem"
"tailscale.com/types/logger"
)
const featureName = "serviceclientprefs"
// storeKey is the key under which a profile's service client prefs are stored.
const storeKey = "service-client-prefs"
func init() {
feature.Register(featureName)
ipnext.RegisterExtension(featureName, newExtension)
}
// newExtension is the [ipnext.NewExtensionFn] for this feature.
func newExtension(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) {
return &extension{
logf: logger.WithPrefix(logf, featureName+": "),
sb: sb,
}, nil
}
// extension stores the service client prefs for the current login profile.
type extension struct {
logf logger.Logf
sb ipnext.SafeBackend
host ipnext.Host // from Init
// mu guards store and curPID, and the read/change/write in setServiceClientPref.
mu sync.Mutex
store ipn.StateStore // for the current profile
curPID ipn.ProfileID // the profile store is built for
}
// Name implements [ipnext.Extension].
func (e *extension) Name() string { return featureName }
// Init implements [ipnext.Extension]. It subscribes to profile changes and sets up storage for the
// current profile.
func (e *extension) Init(h ipnext.Host) error {
e.host = h
h.Hooks().ProfileStateChange.Add(e.onChangeProfile)
profile, prefs := h.Profiles().CurrentProfileState()
e.onChangeProfile(profile, prefs, false)
return nil
}
// Shutdown implements [ipnext.Extension].
func (e *extension) Shutdown() error { return nil }
// onChangeProfile builds the store for the profile we switched to. The prefs live under
// profile-data/<id>/, which [ipnlocal.LocalBackend] removes when the profile is deleted.
func (e *extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsView, sameNode bool) {
e.mu.Lock()
defer e.mu.Unlock()
pid := profile.ID()
if sameNode && e.curPID == pid && e.store != nil {
// Same profile, just a prefs or metadata update; keep the store we already built.
return
}
e.curPID = pid
e.store = nil
if pid == "" {
return
}
varRoot := e.sb.TailscaleVarRoot()
if varRoot == "" {
// No writable storage (ephemeral node, or a non-file [ipn.StateStore] like Kubernetes).
// Service client prefs are only used by the desktop clients, so an in-memory store that
// doesn't survive restarts is fine here.
e.store = new(mem.Store)
return
}
// Store the file under a dir plus a hex-encoded filename with no extension, matching
// netmapcache.Store's layout so a shared store could read these files if we consolidate later.
hexStoreKey := hex.EncodeToString([]byte(storeKey))
path := filepath.Join(varRoot, ipnlocal.ProfileDataDir, string(pid), storeKey, hexStoreKey)
st, err := store.NewFileStore(e.logf, path)
if err != nil {
e.logf("building store for profile %q: %v", pid, err)
return
}
e.store = st
}