diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 7d1f98b3e..c6de32288 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -4204,6 +4204,16 @@ func (b *LocalBackend) DeleteProfile(p ipn.ProfileID) error { return b.resetForProfileChangeLockedOnEntry() } +// DeleteProfiles removes all known profiles. +func (b *LocalBackend) DeleteAllProfiles() error { + b.mu.Lock() + if err := b.pm.DeleteAllProfiles(); err != nil { + b.mu.Unlock() + return err + } + return b.resetForProfileChangeLockedOnEntry() +} + // CurrentProfile returns the current LoginProfile. // The value may be zero if the profile is not persisted. func (b *LocalBackend) CurrentProfile() ipn.LoginProfile { diff --git a/ipn/ipnlocal/profiles.go b/ipn/ipnlocal/profiles.go index 5d14c1d94..5c5c832a5 100644 --- a/ipn/ipnlocal/profiles.go +++ b/ipn/ipnlocal/profiles.go @@ -12,6 +12,7 @@ "runtime" "time" + "golang.org/x/exp/maps" "golang.org/x/exp/slices" "tailscale.com/ipn" "tailscale.com/tailcfg" @@ -349,6 +350,21 @@ func (pm *profileManager) DeleteProfile(id ipn.ProfileID) error { return pm.writeKnownProfiles() } +// DeleteAllProfiles removes all known profiles and switches to a new empty +// profile. +func (pm *profileManager) DeleteAllProfiles() error { + metricDeleteAllProfile.Add(1) + + for _, kp := range pm.knownProfiles { + if err := pm.store.WriteState(kp.Key, nil); err != nil { + return err + } + } + pm.NewProfile() + maps.Clear(pm.knownProfiles) + return pm.writeKnownProfiles() +} + func (pm *profileManager) writeKnownProfiles() error { b, err := json.Marshal(pm.knownProfiles) if err != nil { @@ -512,9 +528,10 @@ func (pm *profileManager) migrateFromLegacyPrefs() error { } var ( - metricNewProfile = clientmetric.NewCounter("profiles_new") - metricSwitchProfile = clientmetric.NewCounter("profiles_switch") - metricDeleteProfile = clientmetric.NewCounter("profiles_delete") + metricNewProfile = clientmetric.NewCounter("profiles_new") + metricSwitchProfile = clientmetric.NewCounter("profiles_switch") + metricDeleteProfile = clientmetric.NewCounter("profiles_delete") + metricDeleteAllProfile = clientmetric.NewCounter("profiles_delete_all") metricMigration = clientmetric.NewCounter("profiles_migration") metricMigrationError = clientmetric.NewCounter("profiles_migration_error") diff --git a/ipn/ipnlocal/profiles_test.go b/ipn/ipnlocal/profiles_test.go index a7ffee5c2..dc45ed6b5 100644 --- a/ipn/ipnlocal/profiles_test.go +++ b/ipn/ipnlocal/profiles_test.go @@ -164,6 +164,14 @@ func TestProfileManagement(t *testing.T) { delete(wantProfiles, "tagged-node.2.ts.net") wantCurProfile = "user@2.example.com" checkProfiles(t) + + t.Logf("Delete all") + pm.DeleteAllProfiles() + wantProfiles = map[string]ipn.PrefsView{ + "": emptyPrefs, + } + wantCurProfile = "" + checkProfiles(t) } // TestProfileManagementWindows tests going into and out of Unattended mode on diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 89366aa41..57928d6ba 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -1156,6 +1156,7 @@ func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { // - GET /profiles/: list all profiles (JSON-encoded array of ipn.LoginProfiles) // - PUT /profiles/: add new profile (no response). A separate // StartLoginInteractive() is needed to populate and persist the new profile. +// - DELETE /profiles/: delete all profile (no response) // - GET /profiles/current: current profile (JSON-ecoded ipn.LoginProfile) // - GET /profiles/: output profile (JSON-ecoded ipn.LoginProfile) // - POST /profiles/: switch to profile (no response) @@ -1182,6 +1183,13 @@ func (h *Handler) serveProfiles(w http.ResponseWriter, r *http.Request) { return } w.WriteHeader(http.StatusCreated) + case http.MethodDelete: + err := h.b.DeleteAllProfiles() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) default: http.Error(w, "use GET or PUT", http.StatusMethodNotAllowed) }