WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
20 changed files with 597 additions and 3 deletions
Showing only changes of commit f4978b4b9b - Show all commits
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serviceclientprefs
package local
import (
"context"
"net/http"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/serviceclientprefs/serviceclient"
)
// GetServiceClientPrefs returns all of the current profile's [serviceclient.Prefs].
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) GetServiceClientPrefs(ctx context.Context) (serviceclient.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs/service-clients")
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
// SetServiceClientPref merges the non-empty fields from an [apitype.ServiceClientPrefRequest] into the
// saved service client prefs for the current profile and returns the full updated set.
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) SetServiceClientPref(ctx context.Context, req apitype.ServiceClientPrefRequest) (serviceclient.Prefs, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/prefs/service-clients", http.StatusOK, jsonBody(req))
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
+17
View File
@@ -104,3 +104,20 @@ type OptionalFeatures struct {
// are not guaranteed to be present.)
Features map[string]bool
}
// ServiceClientPrefRequest is the body POSTed to the LocalAPI endpoint /localapi/v0/prefs/service-clients.
// Empty values for Client, Username, and DatabaseName mean "don't change this value".
type ServiceClientPrefRequest struct {
// Key is the identifier for the service client pref. Required. Format is "<serviceName>:<port>"
// where serviceName is a [tailcfg.ServiceName], e.g. "svc:my-db:5432".
Key string
// Client is the name of the client that the user picked in the service launch. Optional.
Client string `json:",omitzero"`
// Username is the username that the user entered in the service launch. Optional.
Username string `json:",omitzero"`
// DatabaseName is the database name that the user entered in the service launch. Optional.
DatabaseName string `json:",omitzero"`
}
+1
View File
@@ -91,6 +91,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/feature from tailscale.com/tsweb+
tailscale.com/feature/buildfeatures from tailscale.com/feature+
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/local
+1
View File
@@ -741,6 +741,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/feature/netlog from tailscale.com/feature/condregister/netlog
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
+1
View File
@@ -211,6 +211,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/feature/syspolicy from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/gokrazy/mkfs from tailscale.com/cmd/tailscale/cli
+2
View File
@@ -322,6 +322,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/feature/routecheck from tailscale.com/feature/condregister
tailscale.com/feature/runtimemetrics from tailscale.com/feature/condregister
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
tailscale.com/feature/serviceclientprefs from tailscale.com/feature/condregister
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local+
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
L tailscale.com/feature/syslog from tailscale.com/feature/condregister
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister
+12
View File
@@ -13,6 +13,18 @@ import (
"tailscale.com/tstest/deptest"
)
func TestOmitServiceClientPrefs(t *testing.T) {
const msg = "unexpected with ts_omit_serviceclientprefs"
deptest.DepChecker{
GOOS: "linux",
GOARCH: "amd64",
Tags: "ts_omit_serviceclientprefs,ts_include_cli",
BadDeps: map[string]string{
"tailscale.com/feature/serviceclientprefs": msg,
},
}.Check(t)
}
func TestOmitSSH(t *testing.T) {
const msg = "unexpected with ts_omit_ssh"
deptest.DepChecker{
+1
View File
@@ -158,6 +158,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/feature/netlog from tailscale.com/feature/condregister/netlog
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_serviceclientprefs
package buildfeatures
// HasServiceClientPrefs is whether the binary was built with support for modular feature "Desktop client service launch preferences".
// Specifically, it's whether the binary was NOT built with the "ts_omit_serviceclientprefs" build tag.
// It's a const so it can be used for dead code elimination.
const HasServiceClientPrefs = false
@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_serviceclientprefs
package buildfeatures
// HasServiceClientPrefs is whether the binary was built with support for modular feature "Desktop client service launch preferences".
// Specifically, it's whether the binary was NOT built with the "ts_omit_serviceclientprefs" build tag.
// It's a const so it can be used for dead code elimination.
const HasServiceClientPrefs = true
@@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serviceclientprefs
package condregister
import _ "tailscale.com/feature/serviceclientprefs"
+3 -2
View File
@@ -229,8 +229,9 @@ var Features = map[FeatureTag]FeatureMeta{
Desc: "Linux NetworkManager integration",
Deps: []FeatureTag{"dbus"},
},
"qrcodes": {Sym: "QRCodes", Desc: "QR codes in tailscale CLI"},
"relayserver": {Sym: "RelayServer", Desc: "Relay server"},
"serviceclientprefs": {Sym: "ServiceClientPrefs", Desc: "Desktop client service launch preferences"},
"qrcodes": {Sym: "QRCodes", Desc: "QR codes in tailscale CLI"},
"relayserver": {Sym: "RelayServer", Desc: "Relay server"},
"remoteconfig": {
Sym: "RemoteConfig",
Desc: "Full remote configuration of this node by the tailnet admin, opting out of Tailscale's per-feature double opt-in in favor of a single client-side trust decision",
+68
View File
@@ -0,0 +1,68 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package serviceclientprefs
import (
"encoding/json"
"errors"
"net/http"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/serviceclientprefs/serviceclient"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/util/httpm"
)
func init() {
localapi.Register("prefs/service-clients", serveServiceClientPrefs)
}
// serveServiceClientPrefs handles GET and POST /localapi/v0/prefs/service-clients. GET returns all
// of the current profile's service client prefs. POST merges one [apitype.ServiceClientPrefRequest]
// into the saved service client prefs and returns the full updated set.
func serveServiceClientPrefs(h *localapi.Handler, w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "service-clients access denied", http.StatusForbidden)
return
}
ext, ok := ipnlocal.GetExt[*extension](h.LocalBackend())
if !ok {
http.Error(w, "misconfigured serviceclientprefs extension", http.StatusInternalServerError)
return
}
var out serviceclient.Prefs
switch r.Method {
case httpm.GET:
var err error
if out, err = ext.serviceClientPrefs(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
case httpm.POST:
if !h.PermitWrite {
http.Error(w, "service-clients write access denied", http.StatusForbidden)
return
}
var req apitype.ServiceClientPrefRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var err error
if out, err = ext.setServiceClientPref(req); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, errInvalidServiceClientPref) {
status = http.StatusBadRequest
}
http.Error(w, err.Error(), status)
return
}
default:
http.Error(w, "use GET or POST", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
@@ -0,0 +1,98 @@
// 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)
}
@@ -0,0 +1,116 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package serviceclientprefs
import (
"errors"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/serviceclientprefs/serviceclient"
"tailscale.com/ipn"
)
func TestServiceClientPrefsSetAndGet(t *testing.T) {
tests := []struct {
name string
requests []apitype.ServiceClientPrefRequest
want serviceclient.Prefs
}{
{
name: "records_a_single_launch",
requests: []apitype.ServiceClientPrefRequest{{Key: "ssh:22", Client: "terminal", Username: "rollie"}},
want: serviceclient.Prefs{"ssh:22": {Client: "terminal", Username: "rollie"}},
},
{
name: "partial_update_preserves_existing_fields",
requests: []apitype.ServiceClientPrefRequest{
{Key: "ssh:22", Client: "terminal", Username: "rollie"},
{Key: "ssh:22", Client: "iterm2"},
},
want: serviceclient.Prefs{"ssh:22": {Client: "iterm2", Username: "rollie"}},
},
{
name: "independent_services_kept_separate",
requests: []apitype.ServiceClientPrefRequest{
{Key: "ssh:22", Client: "terminal"},
{Key: "db:5432", Client: "psql", DatabaseName: "prod"},
},
want: serviceclient.Prefs{
"ssh:22": {Client: "terminal"},
"db:5432": {Client: "psql", DatabaseName: "prod"},
},
},
}
ignoreLastUsed := cmpopts.IgnoreFields(serviceclient.Pref{}, "LastUsed")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := newTestExtension(t, "pid")
for _, req := range tt.requests {
if _, err := e.setServiceClientPref(req); err != nil {
t.Fatal(err)
}
}
got, err := e.serviceClientPrefs()
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(tt.want, got, ignoreLastUsed, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("(-want +got):\n%s", diff)
}
for key := range tt.want {
if got[key].LastUsed.IsZero() {
t.Errorf("%s: LastUsed was not stamped", key)
}
}
})
}
}
func TestServiceClientPrefsEmptyKeyRejected(t *testing.T) {
e := newTestExtension(t, "pid")
_, err := e.setServiceClientPref(apitype.ServiceClientPrefRequest{Client: "terminal"})
if !errors.Is(err, errInvalidServiceClientPref) {
t.Errorf("want errInvalidServiceClientPref for empty key, got %v", err)
}
}
func TestServiceClientPrefsNoCurrentProfileReturnsEmpty(t *testing.T) {
e := newTestExtension(t, "") // no current profile
got, err := e.serviceClientPrefs()
if err != nil {
t.Fatalf("want nil error for no current profile, got %v", err)
}
if got == nil || len(got) != 0 {
t.Errorf("want empty non-nil map, got %#v", got)
}
}
func TestServiceClientPrefsRepopulatedOnProfileSwitch(t *testing.T) {
e := newTestExtension(t, "pid0")
// Save a pref for pid0.
if _, err := e.setServiceClientPref(apitype.ServiceClientPrefRequest{Key: "ssh:22", Client: "terminal"}); err != nil {
t.Fatal(err)
}
// Switch to a different profile; it must not see pid0's data.
e.onChangeProfile((&ipn.LoginProfile{ID: "pid1"}).View(), ipn.PrefsView{}, false)
if got, _ := e.serviceClientPrefs(); len(got) != 0 {
t.Fatalf("pid1 unexpectedly saw pid0's prefs: %v", got)
}
// Switch back to pid0; its saved pref should be read back from disk into the cache.
e.onChangeProfile((&ipn.LoginProfile{ID: "pid0"}).View(), ipn.PrefsView{}, false)
got, err := e.serviceClientPrefs()
if err != nil {
t.Fatal(err)
}
if got["ssh:22"].Client != "terminal" {
t.Errorf("after switching back, got %v, want Client=terminal", got["ssh:22"])
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package serviceclient holds the types for the desktop clients' saved service launch preferences.
// They're shared by the serviceclientprefs feature and the LocalAPI client.
package serviceclient
import (
"time"
)
// Prefs maps a service key to the user's saved preferences for that service.
// Keys are "<serviceName>:<port>" where serviceName is a [tailcfg.ServiceName]
// (e.g. key "svc:my-db:5432" for service "svc:my-db" on port 5432).
type Prefs map[string]Pref
// Pref captures the saved preferences for one service.
type Pref struct {
// Client is the saved client identifier the user picked in the last service launch,
// for example, terminal/putty/iterm2/... for SSH, and dbeaver/psql/mycli/... for
// database. Empty means no client has been saved for this service.
Client string `json:",omitzero"`
// Username is the saved login name for SSH and future services that require username.
// Empty means none saved.
Username string `json:",omitzero"`
// DatabaseName is the saved DB name (database service types). Empty means none saved.
DatabaseName string `json:",omitzero"`
// LastUsed is when this service was last launched. Zero means never.
// When the macOS and Windows apps generate a "recently used" list, they sort by
// LastUsed descending.
LastUsed time.Time `json:",omitzero"`
}
@@ -0,0 +1,102 @@
// 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
}
@@ -0,0 +1,64 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package serviceclientprefs
import (
"testing"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/tsd"
"tailscale.com/tstest"
"tailscale.com/tstime"
"tailscale.com/types/logger"
)
// fakeBackend is a minimal [ipnext.SafeBackend] for tests. Only Clock and TailscaleVarRoot are
// used by the extension; Sys is never called.
type fakeBackend struct {
clock tstime.Clock
varRoot string
}
func (f *fakeBackend) Sys() *tsd.System { return nil }
func (f *fakeBackend) Clock() tstime.Clock { return f.clock }
func (f *fakeBackend) TailscaleVarRoot() string { return f.varRoot }
// newTestExtension returns an extension backed by a temp var root and a fixed test clock, with its
// current profile set to pid.
func newTestExtension(t *testing.T, pid ipn.ProfileID) *extension {
t.Helper()
e := &extension{
logf: logger.Discard,
sb: &fakeBackend{
clock: tstest.NewClock(tstest.ClockOpts{Start: time.Now()}),
varRoot: t.TempDir(),
},
}
e.onChangeProfile((&ipn.LoginProfile{ID: pid}).View(), ipn.PrefsView{}, false)
return e
}
func TestNoVarRootUsesMemory(t *testing.T) {
e := &extension{
logf: logger.Discard,
sb: &fakeBackend{
clock: tstest.NewClock(tstest.ClockOpts{Start: time.Now()}),
varRoot: "", // force the in-memory fallback
},
}
e.onChangeProfile((&ipn.LoginProfile{ID: "pid"}).View(), ipn.PrefsView{}, false)
if _, err := e.setServiceClientPref(apitype.ServiceClientPrefRequest{Key: "ssh:22", Client: "terminal"}); err != nil {
t.Fatal(err)
}
got, err := e.serviceClientPrefs()
if err != nil {
t.Fatal(err)
}
if got["ssh:22"].Client != "terminal" {
t.Errorf("in-memory store: got %v, want Client=terminal", got["ssh:22"])
}
}
+4 -1
View File
@@ -6296,6 +6296,9 @@ func (b *LocalBackend) ProfileMkdirAll(id ipn.ProfileID, subs ...string) (string
return b.profileMkdirAllLocked(id, subs...)
}
// ProfileDataDir is the directory name, under the Tailscale var root, that holds per-profile data.
const ProfileDataDir = "profile-data"
// profileDataPathLocked returns a path of a profile-specific (sub)directory
// inside the writable storage area for the given profile ID. It does not
// create or verify the existence of the path in the filesystem.
@@ -6310,7 +6313,7 @@ func (b *LocalBackend) profileDataPathLocked(id ipn.ProfileID, subs ...string) s
if vr == "" {
return ""
}
return filepath.Join(append([]string{vr, "profile-data", string(id)}, subs...)...)
return filepath.Join(append([]string{vr, ProfileDataDir, string(id)}, subs...)...)
}
// profileMkdirAllLocked implements ProfileMkdirAll.
+1
View File
@@ -154,6 +154,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/feature/netlog from tailscale.com/feature/condregister/netlog
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal