package registry import ( "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/binary" "encoding/hex" "errors" "fmt" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/klarkxy/nekonest-cloud/relay/internal/attachmentstore" "github.com/klarkxy/nekonest-cloud/relay/internal/authsnapshot" "github.com/klarkxy/nekonest-cloud/relay/internal/controlplane" "github.com/klarkxy/nekonest-cloud/relay/internal/tenantfs" "github.com/klarkxy/nekonest-cloud/relay/internal/tenantstore" "github.com/klarkxy/nekonest/relaycore" "github.com/klarkxy/nekonest/relaycore/protocol" ) type SnapshotSource interface { AuthorizationSnapshot(context.Context, string, int64) (controlplane.SnapshotResponse, error) AuthorizationDelta(context.Context, string, int64) (controlplane.DeltaResponse, error) } type Config struct { DataRoot string NodeID string AllowedOrigins []string RouteSecret []byte AppVersion string MaxTenants int Keyring authsnapshot.Keyring ControlPlane SnapshotSource RefreshInterval time.Duration DeltaInterval time.Duration RequestTimeout time.Duration PushSink relaycore.PushSink AuditSink relaycore.AuditSink Now func() time.Time } type Registry struct { config Config ctx context.Context cancel context.CancelFunc mu sync.RWMutex slots map[string]*slot routeHints map[string]*Tenant phoneRoutes map[string]*Tenant deviceOwners map[string]string phoneOwners map[string]string activeTenants int } type slot struct { mu sync.Mutex tenant *Tenant reserved bool // guarded by Registry.mu; counts active or currently building Engines } type Tenant struct { mu sync.RWMutex engine *relaycore.Engine store *tenantstore.DB handler http.Handler payload authsnapshot.Payload routeHint string cancel context.CancelFunc closed bool deviceIDs map[string]struct{} phoneIDs map[string]struct{} } type attachmentURLBuilder struct{ routeHint string } func (builder attachmentURLBuilder) BuildAttachmentURL(id, capabilityKey string) string { query := make(url.Values) query.Set("route", builder.routeHint) query.Set("k", capabilityKey) return "/api/attachments/" + url.PathEscape(id) + "?" + query.Encode() } func New(config Config) (*Registry, error) { if config.DataRoot == "" || config.NodeID == "" || len(config.RouteSecret) < 32 { return nil, errors.New("registry requires data root, node id, and a 32-byte route secret") } if config.MaxTenants <= 0 { config.MaxTenants = 256 } if config.RefreshInterval <= 0 { config.RefreshInterval = 60 * time.Second } if config.DeltaInterval <= 0 { // Poll more frequently than the public 15-second revocation objective: // one healthy delta request plus one full snapshot refresh must still fit. config.DeltaInterval = 5 * time.Second } if config.RequestTimeout <= 0 { config.RequestTimeout = 3 * time.Second } if config.Now == nil { config.Now = time.Now } if len(config.Keyring) == 0 || config.ControlPlane == nil { return nil, errors.New("registry requires pinned snapshot keys and a control plane") } ctx, cancel := context.WithCancel(context.Background()) return &Registry{ config: config, ctx: ctx, cancel: cancel, slots: make(map[string]*slot), routeHints: make(map[string]*Tenant), phoneRoutes: make(map[string]*Tenant), deviceOwners: make(map[string]string), phoneOwners: make(map[string]string), }, nil } type principalReservation struct { devices []string phones []string } func (r *Registry) reservePrincipals(payload authsnapshot.Payload) (principalReservation, error) { r.mu.Lock() defer r.mu.Unlock() for _, device := range payload.Devices { if owner := r.deviceOwners[device.DeviceID]; owner != "" && owner != payload.TenantID { return principalReservation{}, errors.New("device identity is already owned by another tenant") } } for _, phone := range payload.Phones { if owner := r.phoneOwners[phone.PhoneID]; owner != "" && owner != payload.TenantID { return principalReservation{}, errors.New("phone identity is already owned by another tenant") } } reservation := principalReservation{} for _, device := range payload.Devices { if r.deviceOwners[device.DeviceID] == "" { r.deviceOwners[device.DeviceID] = payload.TenantID reservation.devices = append(reservation.devices, device.DeviceID) } } for _, phone := range payload.Phones { if r.phoneOwners[phone.PhoneID] == "" { r.phoneOwners[phone.PhoneID] = payload.TenantID reservation.phones = append(reservation.phones, phone.PhoneID) } } return reservation, nil } func (r *Registry) releaseReservation(tenantID string, reservation principalReservation) { r.mu.Lock() defer r.mu.Unlock() for _, id := range reservation.devices { if r.deviceOwners[id] == tenantID { delete(r.deviceOwners, id) } } for _, id := range reservation.phones { if r.phoneOwners[id] == tenantID { delete(r.phoneOwners, id) } } } func (r *Registry) releaseRemovedPrincipals(tenantID string, oldDevices, oldPhones map[string]struct{}, payload authsnapshot.Payload) { newDevices := make(map[string]struct{}, len(payload.Devices)) for _, device := range payload.Devices { newDevices[device.DeviceID] = struct{}{} } newPhones := make(map[string]struct{}, len(payload.Phones)) for _, phone := range payload.Phones { newPhones[phone.PhoneID] = struct{}{} } r.mu.Lock() defer r.mu.Unlock() for id := range oldDevices { if _, exists := newDevices[id]; !exists && r.deviceOwners[id] == tenantID { delete(r.deviceOwners, id) } } for id := range oldPhones { if _, exists := newPhones[id]; !exists && r.phoneOwners[id] == tenantID { delete(r.phoneOwners, id) } } } func cloneSet(source map[string]struct{}) map[string]struct{} { result := make(map[string]struct{}, len(source)) for key := range source { result[key] = struct{}{} } return result } func (r *Registry) routeHint(tenantID string, generation int64) string { key := sha256.Sum256(append([]byte("nekonest-cloud/engine-route-key/v1\x00"), r.config.RouteSecret...)) block, err := aes.NewCipher(key[:]) if err != nil { return "" } aead, err := cipher.NewGCM(block) if err != nil { return "" } nonce := make([]byte, aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "" } plaintext := make([]byte, len(tenantID)+8) copy(plaintext, tenantID) binary.BigEndian.PutUint64(plaintext[len(tenantID):], uint64(generation)) sealed := aead.Seal(nil, nonce, plaintext, []byte("nekonest-cloud/engine-route/v1")) token := append([]byte{1}, nonce...) token = append(token, sealed...) return base64.RawURLEncoding.EncodeToString(token) } // DecodeRouteHint authenticates an opaque attachment routing token generated // by any Relay node sharing the deployment route secret. It never accepts a // client-supplied raw tenant id. func (r *Registry) DecodeRouteHint(routeHint string) (string, int64, error) { encoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(routeHint)) if err != nil || len(encoded) < 2 || encoded[0] != 1 { return "", 0, errors.New("invalid route hint") } key := sha256.Sum256(append([]byte("nekonest-cloud/engine-route-key/v1\x00"), r.config.RouteSecret...)) block, err := aes.NewCipher(key[:]) if err != nil { return "", 0, err } aead, err := cipher.NewGCM(block) if err != nil { return "", 0, err } if len(encoded) <= 1+aead.NonceSize() { return "", 0, errors.New("invalid route hint") } nonce := encoded[1 : 1+aead.NonceSize()] plaintext, err := aead.Open(nil, nonce, encoded[1+aead.NonceSize():], []byte("nekonest-cloud/engine-route/v1")) if err != nil || len(plaintext) != len("tenant_")+32+8 { return "", 0, errors.New("invalid route hint") } tenantID := string(plaintext[:len(plaintext)-8]) generation := int64(binary.BigEndian.Uint64(plaintext[len(plaintext)-8:])) if !strings.HasPrefix(tenantID, "tenant_") || generation < 1 { return "", 0, errors.New("invalid route hint") } for _, char := range strings.TrimPrefix(tenantID, "tenant_") { if !strings.ContainsRune("0123456789abcdef", char) { return "", 0, errors.New("invalid route hint") } } return tenantID, generation, nil } func (r *Registry) engineAdminSecret(tenantID string, generation int64) string { mac := hmac.New(sha256.New, r.config.RouteSecret) _, _ = fmt.Fprintf(mac, "nekonest-cloud/engine-admin/v1\x00%s\x00%d", tenantID, generation) return hex.EncodeToString(mac.Sum(nil)) } func (r *Registry) verifyResponse(response controlplane.SnapshotResponse) (authsnapshot.Payload, error) { key, ok := r.config.Keyring[response.Snapshot.KID] if !ok { return authsnapshot.Payload{}, errors.New("snapshot key id is not pinned") } responseKey, responseKID, err := authsnapshot.PublicKeyFromJWK(response.PublicKeyJWK) if err != nil { return authsnapshot.Payload{}, err } if responseKID != "" && responseKID != response.Snapshot.KID { return authsnapshot.Payload{}, errors.New("snapshot JWK kid mismatch") } if len(responseKey) != len(key.PublicKey) || subtle.ConstantTimeCompare(responseKey, key.PublicKey) != 1 { return authsnapshot.Payload{}, errors.New("control-plane key does not match pinned keyring") } payload, err := authsnapshot.Verify(response.Snapshot, r.config.Keyring, r.config.Now()) if err != nil { return authsnapshot.Payload{}, err } if payload.RelayNodeID != r.config.NodeID { return authsnapshot.Payload{}, errors.New("snapshot belongs to another relay node") } return payload, nil } func (r *Registry) getSlot(tenantID string) (*slot, error) { r.mu.Lock() defer r.mu.Unlock() if existing := r.slots[tenantID]; existing != nil { return existing, nil } created := &slot{} r.slots[tenantID] = created return created, nil } func (r *Registry) reserveSlot(slot *slot) error { r.mu.Lock() defer r.mu.Unlock() if slot.reserved { return nil } if r.activeTenants >= r.config.MaxTenants { return errors.New("relay tenant capacity reached") } slot.reserved = true r.activeTenants++ return nil } func (r *Registry) releaseSlot(slot *slot) { r.mu.Lock() defer r.mu.Unlock() if !slot.reserved { return } slot.reserved = false if r.activeTenants > 0 { r.activeTenants-- } } func (r *Registry) Accept(ctx context.Context, response controlplane.SnapshotResponse) (*Tenant, error) { payload, err := r.verifyResponse(response) if err != nil { return nil, err } slot, err := r.getSlot(payload.TenantID) if err != nil { return nil, err } slot.mu.Lock() defer slot.mu.Unlock() if current := slot.tenant; current != nil { current.mu.RLock() generation := current.payload.PlacementGeneration authorizationRevision := current.payload.AuthorizationRevision closed := current.closed oldDevices := cloneSet(current.deviceIDs) oldPhones := cloneSet(current.phoneIDs) current.mu.RUnlock() if !closed && generation == payload.PlacementGeneration { // Snapshot refreshes can complete out of order. Never let an older, // still-valid signed snapshot re-authorize a principal removed by a // newer revision. if payload.AuthorizationRevision < authorizationRevision { return nil, errors.New("stale authorization revision") } reservation, reserveErr := r.reservePrincipals(payload) if reserveErr != nil { return nil, reserveErr } if err := current.applyPayload(payload); err != nil { r.releaseReservation(payload.TenantID, reservation) r.expireLocked(slot, current) r.releaseSlot(slot) return nil, err } r.releaseRemovedPrincipals(payload.TenantID, oldDevices, oldPhones, payload) return current, nil } if payload.PlacementGeneration < generation { return nil, errors.New("stale placement generation") } r.expireLocked(slot, current) } if err := r.reserveSlot(slot); err != nil { return nil, err } reservation, err := r.reservePrincipals(payload) if err != nil { r.releaseSlot(slot) return nil, err } tenant, err := r.buildTenant(payload) if err != nil { r.releaseReservation(payload.TenantID, reservation) r.releaseSlot(slot) return nil, err } slot.tenant = tenant r.mu.Lock() r.routeHints[tenant.routeHint] = tenant r.mu.Unlock() watchContext, cancel := context.WithCancel(r.ctx) tenant.cancel = cancel go r.watch(watchContext, slot, tenant) return tenant, nil } func (r *Registry) buildTenant(payload authsnapshot.Payload) (*Tenant, error) { paths, err := tenantfs.Resolve(r.config.DataRoot, payload.TenantID) if err != nil { return nil, err } store, err := tenantstore.NewWithTransportMode(paths.Database, string(protocol.TransportSealed)) if err != nil { return nil, err } attachments, err := attachmentstore.New(paths.Attachments) if err != nil { _ = store.Close() return nil, err } routeHint := r.routeHint(payload.TenantID, payload.PlacementGeneration) if routeHint == "" { _ = store.Close() return nil, errors.New("generate tenant route hint") } engine, err := relaycore.NewEngine(relaycore.Config{ Store: store, Ports: relaycore.Ports{ PrincipalSynchronizer: store, AttachmentStore: attachments, AttachmentURLBuilder: attachmentURLBuilder{routeHint: routeHint}, PushSink: r.config.PushSink, AuditSink: r.config.AuditSink, }, PhoneSecret: r.engineAdminSecret(payload.TenantID, payload.PlacementGeneration), AppVersion: r.config.AppVersion, TransportMode: protocol.TransportSealed, DuplicateDaemon: relaycore.RejectNew, AllowedOrigins: r.config.AllowedOrigins, }) if err != nil { _ = store.Close() return nil, err } mux := http.NewServeMux() engine.RegisterDataPlaneRoutes(mux) tenant := &Tenant{ engine: engine, store: store, handler: mux, payload: payload, routeHint: routeHint, deviceIDs: make(map[string]struct{}), phoneIDs: make(map[string]struct{}), } if err := tenant.applyPayload(payload); err != nil { _ = engine.Close() _ = store.Close() return nil, err } return tenant, nil } func (tenant *Tenant) applyPayload(payload authsnapshot.Payload) error { tenant.mu.Lock() defer tenant.mu.Unlock() if tenant.closed { return errors.New("tenant engine is closed") } next := make(map[string]struct{}, len(payload.Devices)) for _, device := range payload.Devices { if err := tenant.engine.SyncApprovedDevice(relaycore.ApprovedDevice{ ID: device.DeviceID, Name: device.Name, OS: device.OS, Credential: relaycore.Credential{Kind: relaycore.CredentialSHA256, Value: device.CredentialHash}, }); err != nil { return fmt.Errorf("sync device %s: %w", device.DeviceID, err) } if err := tenant.store.SetDevicePublicKeys( device.DeviceID, device.Ed25519Public, device.X25519Public, device.IdentityFingerprint, ); err != nil { return fmt.Errorf("sync device identity %s: %w", device.DeviceID, err) } next[device.DeviceID] = struct{}{} } for deviceID := range tenant.deviceIDs { if _, exists := next[deviceID]; !exists { if err := tenant.engine.RevokeApprovedDevice(deviceID); err != nil { return fmt.Errorf("revoke device %s: %w", deviceID, err) } } } nextPhones := make(map[string]struct{}, len(payload.Phones)) for _, phone := range payload.Phones { if err := tenant.engine.SyncApprovedPhone(relaycore.ApprovedPhone{ ID: phone.PhoneID, Name: phone.Name, Credential: relaycore.Credential{Kind: relaycore.CredentialSHA256, Value: phone.CredentialHash}, }); err != nil { return fmt.Errorf("sync phone %s: %w", phone.PhoneID, err) } if err := tenant.store.SetPhonePublicKeys(phone.PhoneID, phone.Ed25519Public, phone.X25519Public); err != nil { return fmt.Errorf("sync phone identity %s: %w", phone.PhoneID, err) } nextPhones[phone.PhoneID] = struct{}{} } for phoneID := range tenant.phoneIDs { if _, exists := nextPhones[phoneID]; !exists { if err := tenant.engine.RevokeApprovedPhone(phoneID); err != nil { return fmt.Errorf("revoke phone %s: %w", phoneID, err) } } } tenant.deviceIDs = next tenant.phoneIDs = nextPhones tenant.payload = payload return nil } func (r *Registry) watch(ctx context.Context, slot *slot, tenant *Tenant) { refresh := time.NewTicker(r.config.RefreshInterval) delta := time.NewTicker(r.config.DeltaInterval) defer refresh.Stop() defer delta.Stop() for { tenant.mu.RLock() payload := tenant.payload closed := tenant.closed tenant.mu.RUnlock() if closed { return } expiresAt, err := time.Parse(time.RFC3339Nano, payload.ExpiresAt) if err != nil { r.expire(slot, tenant) return } untilExpiry := expiresAt.Sub(r.config.Now()) if untilExpiry <= 0 { r.expire(slot, tenant) return } expiry := time.NewTimer(untilExpiry) select { case <-ctx.Done(): expiry.Stop() return case <-expiry.C: r.expire(slot, tenant) return case <-refresh.C: expiry.Stop() r.refresh(ctx, tenant, payload) case <-delta.C: expiry.Stop() requestContext, cancel := context.WithTimeout(ctx, r.config.RequestTimeout) change, err := r.config.ControlPlane.AuthorizationDelta( requestContext, payload.TenantID, payload.AuthorizationRevision, ) cancel() if err == nil && change.TenantStatus != "active" { r.expire(slot, tenant) return } if err == nil && change.Changed { r.refresh(ctx, tenant, payload) } } } } func (r *Registry) refresh(ctx context.Context, tenant *Tenant, payload authsnapshot.Payload) { requestContext, cancel := context.WithTimeout(ctx, r.config.RequestTimeout) response, err := r.config.ControlPlane.AuthorizationSnapshot( requestContext, payload.TenantID, payload.PlacementGeneration, ) cancel() if err != nil { return // existing associations continue only until the current snapshot expires } _, _ = r.Accept(ctx, response) } func (r *Registry) expire(slot *slot, tenant *Tenant) { slot.mu.Lock() defer slot.mu.Unlock() if slot.tenant == tenant { r.expireLocked(slot, tenant) r.releaseSlot(slot) } } func (r *Registry) expireLocked(slot *slot, tenant *Tenant) { tenant.mu.Lock() if tenant.closed { tenant.mu.Unlock() if slot.tenant == tenant { slot.tenant = nil } return } tenant.closed = true cancel := tenant.cancel tenantID := tenant.payload.TenantID deviceIDs := cloneSet(tenant.deviceIDs) phoneIDs := cloneSet(tenant.phoneIDs) tenant.mu.Unlock() if cancel != nil { cancel() } _ = tenant.engine.Close() _ = tenant.store.Close() r.mu.Lock() for id := range deviceIDs { if r.deviceOwners[id] == tenantID { delete(r.deviceOwners, id) } } for id := range phoneIDs { if r.phoneOwners[id] == tenantID { delete(r.phoneOwners, id) } } if r.routeHints[tenant.routeHint] == tenant { delete(r.routeHints, tenant.routeHint) } for digest, routed := range r.phoneRoutes { if routed == tenant { delete(r.phoneRoutes, digest) } } r.mu.Unlock() if slot.tenant == tenant { slot.tenant = nil } } func (r *Registry) ByRouteHint(routeHint string) (*Tenant, bool) { r.mu.RLock() tenant := r.routeHints[routeHint] r.mu.RUnlock() if tenant == nil { return nil, false } tenant.mu.RLock() defer tenant.mu.RUnlock() expiresAt, err := time.Parse(time.RFC3339Nano, tenant.payload.ExpiresAt) return tenant, !tenant.closed && err == nil && r.config.Now().Before(expiresAt) } func phoneRouteDigest(routeHandle string) string { digest := sha256.Sum256([]byte(routeHandle)) return hex.EncodeToString(digest[:]) } // BindPhoneRoute caches only a one-way digest of the opaque handle. The handle // is a routing hint and never authorizes a request without the phone token. func (r *Registry) BindPhoneRoute(routeHandle string, tenant *Tenant) error { if len(routeHandle) != 64 || tenant == nil { return errors.New("invalid phone route handle") } for _, char := range routeHandle { if !strings.ContainsRune("0123456789abcdef", char) { return errors.New("invalid phone route handle") } } if !tenant.Current(r.config.Now()) { return errors.New("tenant authorization snapshot expired") } r.mu.Lock() r.phoneRoutes[phoneRouteDigest(routeHandle)] = tenant r.mu.Unlock() return nil } func (r *Registry) ByPhoneRoute(routeHandle string) (*Tenant, bool) { r.mu.RLock() tenant := r.phoneRoutes[phoneRouteDigest(routeHandle)] r.mu.RUnlock() return tenant, tenant != nil && tenant.Current(r.config.Now()) } func (r *Registry) ByDeviceID(deviceID string) (*Tenant, bool) { r.mu.RLock() slots := make([]*slot, 0, len(r.slots)) for _, item := range r.slots { slots = append(slots, item) } r.mu.RUnlock() for _, item := range slots { item.mu.Lock() tenant := item.tenant item.mu.Unlock() if tenant == nil || !tenant.Current(r.config.Now()) { continue } tenant.mu.RLock() _, exists := tenant.deviceIDs[deviceID] tenant.mu.RUnlock() if exists { return tenant, true } } return nil, false } func (tenant *Tenant) Handler() http.Handler { return tenant.handler } func (tenant *Tenant) Engine() *relaycore.Engine { return tenant.engine } func (tenant *Tenant) RouteHint() string { return tenant.routeHint } func (tenant *Tenant) Current(now time.Time) bool { tenant.mu.RLock() defer tenant.mu.RUnlock() expiresAt, err := time.Parse(time.RFC3339Nano, tenant.payload.ExpiresAt) return !tenant.closed && err == nil && now.Before(expiresAt) } func (tenant *Tenant) Payload() authsnapshot.Payload { tenant.mu.RLock() defer tenant.mu.RUnlock() return tenant.payload } func (tenant *Tenant) SyncPhone(phoneID, name, tokenHash, ed25519Public, x25519Public string) error { if err := tenant.engine.SyncApprovedPhone(relaycore.ApprovedPhone{ ID: phoneID, Name: name, Credential: relaycore.Credential{Kind: relaycore.CredentialSHA256, Value: tokenHash}, }); err != nil { return err } return tenant.store.SetPhonePublicKeys(phoneID, ed25519Public, x25519Public) } func (tenant *Tenant) RevokePhone(phoneID string) error { return tenant.engine.RevokeApprovedPhone(phoneID) } // Quiesce closes the current generation and releases its in-process capacity // before a backup is taken. The control plane must already have fenced the // placement out of active state, so a new signed snapshot cannot reopen it. func (r *Registry) Quiesce(tenantID string, generation int64) (tenantfs.Paths, error) { paths, err := tenantfs.Derive(r.config.DataRoot, tenantID) if err != nil { return tenantfs.Paths{}, err } r.mu.RLock() slot := r.slots[tenantID] r.mu.RUnlock() if slot == nil { info, statErr := os.Lstat(paths.Database) if statErr == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular() { return paths, nil // idempotent retry after the Engine was already closed } return tenantfs.Paths{}, errors.New("tenant engine is not active on this node") } slot.mu.Lock() defer slot.mu.Unlock() tenant := slot.tenant if tenant == nil { info, statErr := os.Lstat(paths.Database) if statErr == nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular() { return paths, nil } return tenantfs.Paths{}, errors.New("tenant engine is not active on this node") } tenant.mu.RLock() currentGeneration := tenant.payload.PlacementGeneration tenant.mu.RUnlock() if generation < 1 || currentGeneration != generation { return tenantfs.Paths{}, errors.New("tenant placement generation mismatch") } r.expireLocked(slot, tenant) r.releaseSlot(slot) return paths, nil } // QuiesceForPurge fences and closes an active generation, but unlike migration // quiescing it also succeeds when a retry finds that the tenant data was // already deleted. The exact generation is still enforced for a live Engine. func (r *Registry) QuiesceForPurge(tenantID string, generation int64) error { paths, err := tenantfs.Derive(r.config.DataRoot, tenantID) if err != nil { return err } if generation < 1 { return errors.New("invalid tenant placement generation") } r.mu.RLock() slot := r.slots[tenantID] r.mu.RUnlock() if slot == nil { return validPurgeRoot(paths.Root) } slot.mu.Lock() defer slot.mu.Unlock() tenant := slot.tenant if tenant == nil { return validPurgeRoot(paths.Root) } tenant.mu.RLock() currentGeneration := tenant.payload.PlacementGeneration tenant.mu.RUnlock() if currentGeneration != generation { return errors.New("tenant placement generation mismatch") } r.expireLocked(slot, tenant) r.releaseSlot(slot) return nil } func validPurgeRoot(path string) error { info, err := os.Lstat(path) if os.IsNotExist(err) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return errors.New("tenant purge root is not a real directory") } return nil } func (r *Registry) Close() error { r.cancel() r.mu.RLock() slots := make([]*slot, 0, len(r.slots)) for _, slot := range r.slots { slots = append(slots, slot) } r.mu.RUnlock() for _, slot := range slots { slot.mu.Lock() if slot.tenant != nil { r.expireLocked(slot, slot.tenant) r.releaseSlot(slot) } slot.mu.Unlock() } return nil }