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>
99 lines
3.0 KiB
Go
99 lines
3.0 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package serviceclientprefs
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
|
|
"tailscale.com/client/tailscale/apitype"
|
|
"tailscale.com/feature/serviceclientprefs/serviceclient"
|
|
"tailscale.com/ipn"
|
|
)
|
|
|
|
// errInvalidServiceClientPref is returned by [extension.setServiceClientPref] for a request that
|
|
// fails validation, such as a missing key. The handler maps it to a 400, unlike backend failures.
|
|
var errInvalidServiceClientPref = errors.New("service client pref key is required")
|
|
|
|
// serviceClientPrefs returns the saved service client prefs for the current profile. It returns an
|
|
// empty, non-nil map when nothing has been saved yet, or when there's no current profile (a logged
|
|
// out node simply has no service client prefs).
|
|
func (e *extension) serviceClientPrefs() (serviceclient.Prefs, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.loadLocked()
|
|
}
|
|
|
|
// setServiceClientPref merges the non-empty fields from [apitype.ServiceClientPrefRequest] into the
|
|
// saved pref for its key, stamps [serviceclient.Pref.LastUsed] with the current time, and returns
|
|
// the full updated service client prefs for the current profile.
|
|
func (e *extension) setServiceClientPref(req apitype.ServiceClientPrefRequest) (serviceclient.Prefs, error) {
|
|
if req.Key == "" {
|
|
return nil, errInvalidServiceClientPref
|
|
}
|
|
|
|
// Hold mu across the whole read, change, and write so two writers can't clobber each other,
|
|
// since each writes back the full prefs map.
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
prefs, err := e.loadLocked()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pref := prefs[req.Key]
|
|
if req.Client != "" {
|
|
pref.Client = req.Client
|
|
}
|
|
if req.Username != "" {
|
|
pref.Username = req.Username
|
|
}
|
|
if req.DatabaseName != "" {
|
|
pref.DatabaseName = req.DatabaseName
|
|
}
|
|
pref.LastUsed = e.sb.Clock().Now()
|
|
prefs[req.Key] = pref
|
|
|
|
if err := e.saveLocked(prefs); err != nil {
|
|
return nil, err
|
|
}
|
|
return prefs, nil
|
|
}
|
|
|
|
// loadLocked reads the current profile's prefs, returning an empty map when there's no store (no
|
|
// current profile) or nothing saved yet. The caller must hold e.mu.
|
|
func (e *extension) loadLocked() (serviceclient.Prefs, error) {
|
|
if e.store == nil {
|
|
return serviceclient.Prefs{}, nil
|
|
}
|
|
data, err := e.store.ReadState(storeKey)
|
|
if errors.Is(err, ipn.ErrStateNotExist) {
|
|
return serviceclient.Prefs{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var prefs serviceclient.Prefs
|
|
if err := json.Unmarshal(data, &prefs); err != nil {
|
|
return nil, err
|
|
}
|
|
if prefs == nil {
|
|
prefs = serviceclient.Prefs{}
|
|
}
|
|
return prefs, nil
|
|
}
|
|
|
|
// saveLocked writes prefs to the current profile's store. It returns an error when there's no
|
|
// store to persist to (no current profile, or the store failed to build). The caller must hold e.mu.
|
|
func (e *extension) saveLocked(prefs serviceclient.Prefs) error {
|
|
if e.store == nil {
|
|
return errors.New("no store available to persist service client prefs")
|
|
}
|
|
data, err := json.Marshal(prefs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.store.WriteState(storeKey, data)
|
|
}
|