[management] Withdraw a cluster address claim that is lost after the write

A cluster address is claimed two ways: an account-scoped proxy row, and an
agent network gateway pin on the address. Each side checked the other
before writing — IsClusterAddressAvailable before SaveProxy,
HasForeignAccountProxyAtHost before the settings insert — but check and
write are separate autocommit statements, so two concurrent claimants could
each pass their check and both commit, leaving a pin no proxy will ever
serve next to the proxy row that displaces it.

Both sides now re-read after they write. Manager.Connect re-asks
availability once the proxy row is committed and, if the address is no
longer free or the answer is inconclusive, deletes its own row and returns
ErrClusterAddressUnavailable, which the connect path reports as
AlreadyExists exactly as the pre-write check would have. bootstrapLabeled
re-asks ownership once the settings row is committed and withdraws the pin
on the same terms. Because both write before they re-read, of two
concurrent claimants at least one re-reads after the other has committed
and backs off — on sqlite, postgres and mysql alike, since each statement
sees every commit before it. Both may back off, which costs a retry;
neither keeps a claim the other holds.

No lock spans the proxies and settings tables portably, and a claims table
would be more machinery than the property needs, so the re-read is the
whole mechanism. DeleteProxy is session-guarded like DisconnectProxy, so a
stale session withdrawing itself cannot take out a newer session's row.

Reported by CodeRabbit on #7402 (CWE-362).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sa3DsBDP3VciAi4PPG17L6
This commit is contained in:
mlsmaycon
2026-09-12 12:53:15 +00:00
co-authored by Claude Fable 5.1
parent 39f8ea3f70
commit 68da6bf3aa
11 changed files with 401 additions and 6 deletions
+19
View File
@@ -6292,6 +6292,25 @@ func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID strin
return nil
}
// DeleteProxy removes the proxy's row, but only while it still carries the
// given session: a registration withdrawing its own claim must not take out a
// newer session's row for the same proxy. A row already superseded or gone is
// not an error — the claim it would have withdrawn is no longer this session's
// to withdraw.
func (s *SqlStore) DeleteProxy(ctx context.Context, proxyID, sessionID string) error {
result := s.db.
Where("id = ? AND session_id = ?", proxyID, sessionID).
Delete(&proxy.Proxy{})
if result.Error != nil {
log.WithContext(ctx).Errorf("failed to delete proxy %s session %s: %v", proxyID, sessionID, result.Error)
return status.Errorf(status.Internal, "failed to delete proxy")
}
if result.RowsAffected == 0 {
log.WithContext(ctx).Debugf("proxy %s session %s: no row deleted (superseded by newer session)", proxyID, sessionID)
}
return nil
}
// GetAllProxies returns all reverse proxy instance rows.
func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) {
var proxies []*proxy.Proxy
@@ -154,3 +154,47 @@ func TestSqlStore_GetAllProxies_Empty(t *testing.T) {
assert.Empty(t, all)
})
}
// TestSqlStore_DeleteProxy guards the withdrawal a registration makes when
// its claim on a cluster address is lost after the row was written:
//
// 1. The delete is session-guarded, like DisconnectProxy — a stale session
// withdrawing itself must not take out the row a newer session of the
// same proxy has since written.
// 2. A row that is already gone, or already superseded, is not an error;
// the claim it would have withdrawn is no longer this session's.
// 3. Other proxies at the same address are untouched: only the one row is
// withdrawn, not the cluster.
func TestSqlStore_DeleteProxy(t *testing.T) {
if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
accountID := "acct-withdraw"
now := time.Now()
for _, p := range []*rpproxy.Proxy{
{ID: "p-withdrawn", SessionID: "sess-new", ClusterAddress: "byop.example.com", LastSeen: now, Status: rpproxy.StatusConnected, AccountID: &accountID},
{ID: "p-neighbour", SessionID: "sess-1", ClusterAddress: "byop.example.com", LastSeen: now, Status: rpproxy.StatusConnected, AccountID: &accountID},
} {
require.NoError(t, store.SaveProxy(ctx, p))
}
require.NoError(t, store.DeleteProxy(ctx, "p-withdrawn", "sess-old"),
"a delete under a superseded session must be a no-op, not an error")
remaining, err := store.GetAllProxies(ctx)
require.NoError(t, err)
assert.Len(t, remaining, 2, "a superseded session must not withdraw the newer session's row")
require.NoError(t, store.DeleteProxy(ctx, "p-withdrawn", "sess-new"))
remaining, err = store.GetAllProxies(ctx)
require.NoError(t, err)
require.Len(t, remaining, 1, "the withdrawing session's own row must be gone")
assert.Equal(t, "p-neighbour", remaining[0].ID, "the other proxy at the address must be untouched")
require.NoError(t, store.DeleteProxy(ctx, "p-withdrawn", "sess-new"),
"withdrawing a row that is already gone must be a no-op, not an error")
})
}
+4
View File
@@ -325,6 +325,7 @@ type Store interface {
SaveProxy(ctx context.Context, proxy *proxy.Proxy) error
DisconnectProxy(ctx context.Context, proxyID, sessionID string) error
DeleteProxy(ctx context.Context, proxyID, sessionID string) error
UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error
GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error)
GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error)
@@ -628,6 +629,9 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
func(db *gorm.DB) error {
return migration.MigrateAgentNetworkSettingsToDomain(ctx, db)
},
func(db *gorm.DB) error {
return migration.NormalizeAgentNetworkSettingsIdentity(ctx, db)
},
}
}
+14
View File
@@ -754,6 +754,20 @@ func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChec
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockStore)(nil).DeletePostureChecks), ctx, accountID, postureChecksID)
}
// DeleteProxy mocks base method.
func (m *MockStore) DeleteProxy(ctx context.Context, proxyID, sessionID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteProxy", ctx, proxyID, sessionID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteProxy indicates an expected call of DeleteProxy.
func (mr *MockStoreMockRecorder) DeleteProxy(ctx, proxyID, sessionID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProxy", reflect.TypeOf((*MockStore)(nil).DeleteProxy), ctx, proxyID, sessionID)
}
// DeleteRoute mocks base method.
func (m *MockStore) DeleteRoute(ctx context.Context, accountID, routeID string) error {
m.ctrl.T.Helper()