ipn/store: make WriteState(id, nil) delete key instead of adding nil entry (#19920)

All StateStore implementations store a nil value in the cache map when WriteState is called with a nil byte slice instead of deleting the key. This causes ReadState to return (nil, nil) instead of (nil, ErrStateNotExist), since the key is still present in the map.

This breaks reset-auth in Windows, Linux, and Android, and the node can't log back in without manually editing the state file. (macOS uses a different state store)
DeleteProfile, DeleteAllProfilesForUser, setUnattendedModeAsConfigured are impacted but don't seem to break because the deleted keys are not reread.

This deletes the key from the cache instead.

Fixes tailscale/corp#42477

Signed-off-by: kari-ts <kari@tailscale.com>
This commit is contained in:
kari-ts
2026-05-29 11:22:14 -07:00
committed by GitHub
parent 3d5102090f
commit 7355116c05
9 changed files with 194 additions and 10 deletions
+26
View File
@@ -147,6 +147,9 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) (err error) {
s.memory.WriteState(ipn.StateKey(sanitizeKey(id)), bs)
}
}()
if bs == nil {
return s.removeSecretField(string(id), s.secretName)
}
return s.updateSecret(map[string][]byte{string(id): bs}, s.secretName)
}
@@ -339,6 +342,29 @@ func (s *Store) updateSecret(data map[string][]byte, secretName string) (err err
return nil
}
func (s *Store) removeSecretField(key, secretName string) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if s.canPatchSecret(secretName) {
return s.client.JSONPatchResource(ctx, secretName, kubeclient.TypeSecrets, []kubeclient.JSONPatch{
{
Op: "remove",
Path: "/data/" + sanitizeKey(ipn.StateKey(key)),
},
})
}
// No patch permissions, use UPDATE: get the secret, delete the key, update.
secret, err := s.client.GetSecret(ctx, secretName)
if err != nil {
return fmt.Errorf("error getting Secret %s: %w", secretName, err)
}
delete(secret.Data, sanitizeKey(ipn.StateKey(key)))
if err := s.client.UpdateSecret(ctx, secret); err != nil {
return fmt.Errorf("error updating Secret %s: %w", secretName, err)
}
return nil
}
func (s *Store) loadState() (err error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
+40 -5
View File
@@ -172,6 +172,32 @@ func TestWriteState(t *testing.T) {
},
allowPatch: true,
},
{
name: "delete_with_patch",
initial: map[string][]byte{
"foo": []byte("bar"),
"baz": []byte("quux"),
},
key: "foo",
value: nil,
wantData: map[string][]byte{
"baz": []byte("quux"),
},
allowPatch: true,
},
{
name: "delete_with_update",
initial: map[string][]byte{
"foo": []byte("bar"),
"baz": []byte("quux"),
},
key: "foo",
value: nil,
wantData: map[string][]byte{
"baz": []byte("quux"),
},
allowPatch: false,
},
}
for _, tt := range tests {
@@ -208,6 +234,9 @@ func TestWriteState(t *testing.T) {
} else if p.Op == "add" && strings.HasPrefix(p.Path, "/data/") {
key := strings.TrimPrefix(p.Path, "/data/")
secret[key] = p.Value.([]byte)
} else if p.Op == "remove" && strings.HasPrefix(p.Path, "/data/") {
key := strings.TrimPrefix(p.Path, "/data/")
delete(secret, key)
}
}
return nil
@@ -234,11 +263,17 @@ func TestWriteState(t *testing.T) {
// Verify memory store was updated
got, err := s.memory.ReadState(ipn.StateKey(sanitizeKey(string(tt.key))))
if err != nil {
t.Errorf("reading from memory store: %v", err)
}
if !cmp.Equal(got, tt.value) {
t.Errorf("memory store key %q = %v, want %v", tt.key, got, tt.value)
if tt.value == nil {
if err != ipn.ErrStateNotExist {
t.Errorf("reading deleted key from memory store: got err %v, want ErrStateNotExist", err)
}
} else {
if err != nil {
t.Errorf("reading from memory store: %v", err)
}
if !cmp.Equal(got, tt.value) {
t.Errorf("memory store key %q = %v, want %v", tt.key, got, tt.value)
}
}
})
}
+5 -1
View File
@@ -49,7 +49,11 @@ func (s *Store) WriteState(id ipn.StateKey, bs []byte) error {
if s.cache == nil {
s.cache = map[ipn.StateKey][]byte{}
}
s.cache[id] = bytes.Clone(bs)
if bs == nil {
delete(s.cache, id)
} else {
s.cache[id] = bytes.Clone(bs)
}
return nil
}
+5 -1
View File
@@ -214,7 +214,11 @@ func (s *FileStore) WriteState(id ipn.StateKey, bs []byte) error {
if bytes.Equal(s.cache[id], bs) {
return nil
}
s.cache[id] = bytes.Clone(bs)
if bs == nil {
delete(s.cache, id)
} else {
s.cache[id] = bytes.Clone(bs)
}
bs, err := json.MarshalIndent(s.cache, "", " ")
if err != nil {
return err
+36
View File
@@ -135,11 +135,41 @@ func testStoreSemantics(t *testing.T, store ipn.StateStore) {
}
}
func testStoreDeleteSemantics(t *testing.T, store ipn.StateStore) {
t.Helper()
// Write a key, verify it exists.
if err := store.WriteState("delme", []byte("val")); err != nil {
t.Fatalf("WriteState: %v", err)
}
if bs, err := store.ReadState("delme"); err != nil {
t.Fatalf("ReadState after write: %v", err)
} else if string(bs) != "val" {
t.Fatalf("ReadState after write: got %q, want %q", bs, "val")
}
// Delete by writing nil.
if err := store.WriteState("delme", nil); err != nil {
t.Fatalf("WriteState(nil): %v", err)
}
// Read should return ErrStateNotExist.
if _, err := store.ReadState("delme"); err != ipn.ErrStateNotExist {
t.Fatalf("ReadState after delete: got err %v, want ErrStateNotExist", err)
}
// Delete of a non-existent key should not error.
if err := store.WriteState("never-existed", nil); err != nil {
t.Fatalf("WriteState(nil) on non-existent key: %v", err)
}
}
func TestMemoryStore(t *testing.T) {
tstest.PanicOnLog()
store := new(mem.Store)
testStoreSemantics(t, store)
testStoreDeleteSemantics(t, store)
}
func TestFileStore(t *testing.T) {
@@ -154,6 +184,7 @@ func TestFileStore(t *testing.T) {
}
testStoreSemantics(t, store)
testStoreDeleteSemantics(t, store)
// Build a brand new file store and check that both IDs written
// above are still there.
@@ -176,4 +207,9 @@ func TestFileStore(t *testing.T) {
t.Errorf("reading %q (2nd store): got %q, want %q", key, bs, want)
}
}
// Verify deleted key is still gone after reload.
if _, err := store.ReadState("delme"); err != ipn.ErrStateNotExist {
t.Fatalf("reading deleted key from reloaded store: got err %v, want ErrStateNotExist", err)
}
}