ipn/ipnlocal: manage per-profile subdirectories in TailscaleVarRoot (#18485)

In order to better manage per-profile data resources on the client, add methods
to the LocalBackend to support creation of per-profile directory structures in
local storage. These methods build on the existing TailscaleVarRoot config, and
have the same limitation (i.e., if no local storage is available, it will
report an error when used).

The immediate motivation is to support netmap caching, but we can also use this
mechanism for other per-profile resources including pending taildrop files and
Tailnet Lock authority caches.

This commit only adds the directory-management plumbing; later commits will
handle migrating taildrop, TKA, etc. to this mechanism, as well as caching
network maps.

Updates #12639

Change-Id: Ia75741955c7bf885e49c1ad99f856f669a754169
Signed-off-by: M. J. Fromberger <fromberger@tailscale.com>
main
M. J. Fromberger 8 hours ago committed by GitHub
parent df54751725
commit ce12863ee5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -21,6 +21,7 @@ import (
"net/netip"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"slices"
@ -165,6 +166,10 @@ var (
// errManagedByPolicy indicates the operation is blocked
// because the target state is managed by a GP/MDM policy.
errManagedByPolicy = errors.New("managed by policy")
// ErrProfileStorageUnavailable indicates that profile-specific local data
// storage is not available; see [LocalBackend.ProfileMkdirAll].
ErrProfileStorageUnavailable = errors.New("profile local data storage unavailable")
)
// LocalBackend is the glue between the major pieces of the Tailscale
@ -5228,6 +5233,56 @@ func (b *LocalBackend) TailscaleVarRoot() string {
return ""
}
// ProfileMkdirAll creates (if necessary) and returns the path of a directory
// specific to the specified login profile, inside Tailscale's writable storage
// area. If subs are provided, they are joined to the base path to form the
// subdirectory path.
//
// It reports [ErrProfileStorageUnavailable] if there's no configured or
// discovered storage location, or if there was an error making the
// subdirectory.
func (b *LocalBackend) ProfileMkdirAll(id ipn.ProfileID, subs ...string) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.profileMkdirAllLocked(id, subs...)
}
// 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.
// If b.varRoot == "", it returns "". It panics if id is empty.
//
// The caller must hold b.mu.
func (b *LocalBackend) profileDataPathLocked(id ipn.ProfileID, subs ...string) string {
if id == "" {
panic("invalid empty profile ID")
}
vr := b.TailscaleVarRoot()
if vr == "" {
return ""
}
return filepath.Join(append([]string{vr, "profile-data", string(id)}, subs...)...)
}
// profileMkdirAllLocked implements ProfileMkdirAll.
// The caller must hold b.mu.
func (b *LocalBackend) profileMkdirAllLocked(id ipn.ProfileID, subs ...string) (string, error) {
if id == "" {
return "", errProfileNotFound
}
if vr := b.TailscaleVarRoot(); vr == "" {
return "", ErrProfileStorageUnavailable
}
// Use the LoginProfile ID rather than the UserProfile ID, as the latter may
// change over time.
dir := b.profileDataPathLocked(id, subs...)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create profile directory: %w", err)
}
return dir, nil
}
// closePeerAPIListenersLocked closes any existing PeerAPI listeners
// and clears out the PeerAPI server state.
//
@ -7011,6 +7066,12 @@ func (b *LocalBackend) DeleteProfile(p ipn.ProfileID) error {
}
return err
}
// Make a best-effort to remove the profile-specific data directory, if one exists.
if pd := b.profileDataPathLocked(p); pd != "" {
if err := os.RemoveAll(pd); err != nil {
b.logf("warning: removing profile data for %q: %v", p, err)
}
}
if !needToRestart {
return nil
}

@ -2306,6 +2306,56 @@ func TestDNSConfigForNetmapForExitNodeConfigs(t *testing.T) {
}
}
func TestProfileMkdirAll(t *testing.T) {
t.Run("NoVarRoot", func(t *testing.T) {
b := newTestBackend(t)
b.SetVarRoot("")
got, err := b.ProfileMkdirAll(b.CurrentProfile().ID())
if got != "" || !errors.Is(err, ErrProfileStorageUnavailable) {
t.Errorf(`ProfileMkdirAll: got %q, %v; want "", %v`, got, err, ErrProfileStorageUnavailable)
}
})
t.Run("InvalidProfileID", func(t *testing.T) {
b := newTestBackend(t)
got, err := b.ProfileMkdirAll("")
if got != "" || !errors.Is(err, errProfileNotFound) {
t.Errorf("ProfileMkdirAll: got %q, %v; want %q, %v", got, err, "", errProfileNotFound)
}
})
t.Run("ProfileRoot", func(t *testing.T) {
b := newTestBackend(t)
want := filepath.Join(b.TailscaleVarRoot(), "profile-data", "id0")
got, err := b.ProfileMkdirAll(b.CurrentProfile().ID())
if err != nil || got != want {
t.Errorf("ProfileMkdirAll: got %q, %v, want %q, nil", got, err, want)
}
if fi, err := os.Stat(got); err != nil {
t.Errorf("Check directory: %v", err)
} else if !fi.IsDir() {
t.Errorf("Path %q is not a directory", got)
}
})
t.Run("ProfileSubdir", func(t *testing.T) {
b := newTestBackend(t)
want := filepath.Join(b.TailscaleVarRoot(), "profile-data", "id0", "a", "b")
got, err := b.ProfileMkdirAll(b.CurrentProfile().ID(), "a", "b")
if err != nil || got != want {
t.Errorf("ProfileMkdirAll: got %q, %v, want %q, nil", got, err, want)
}
if fi, err := os.Stat(got); err != nil {
t.Errorf("Check directory: %v", err)
} else if !fi.IsDir() {
t.Errorf("Path %q is not a directory", got)
}
})
}
func TestOfferingAppConnector(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
b := newTestBackend(t)

Loading…
Cancel
Save