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>
103 lines
3.2 KiB
Go
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
|
|
}
|