package main // Port of lib/store.js: the whole database is one JSON file, rewritten // atomically (temp file + rename, mode 0600) on every durable transaction. // JS serializes concurrent operations on a promise queue; a mutex provides // the same serialization here. import ( "encoding/base64" "errors" "fmt" "os" "path/filepath" "sync" ) // ErrSkip is the counterpart of JS Store.SKIP: a Transaction mutator returns // it (wrapped or directly) to abort without persisting. var ErrSkip = errors.New("no data changes") // collections lists the validated collections in the JS COLLECTIONS order; // validation error messages name the first offending one. var collections = []struct { name string isNil func(d *Data) bool }{ {"users", func(d *Data) bool { return d.Users == nil }}, {"servers", func(d *Data) bool { return d.Servers == nil }}, {"agents", func(d *Data) bool { return d.Agents == nil }}, {"enrollmentTokens", func(d *Data) bool { return d.EnrollmentTokens == nil }}, {"customers", func(d *Data) bool { return d.Customers == nil }}, {"nodes", func(d *Data) bool { return d.Nodes == nil }}, {"nodeClients", func(d *Data) bool { return d.NodeClients == nil }}, {"jobs", func(d *Data) bool { return d.Jobs == nil }}, {"activity", func(d *Data) bool { return d.Activity == nil }}, {"observations", func(d *Data) bool { return d.Observations == nil }}, {"subscriptionAccess", func(d *Data) bool { return d.SubscriptionAccess == nil }}, {"subscriptionClients", func(d *Data) bool { return d.SubscriptionClients == nil }}, } // EmptyData mirrors JS emptyData(). func EmptyData() *Data { return &Data{ SchemaVersion: 1, CreatedAt: NowISO(), Settings: Settings{ BrandName: "amo", SessionHours: 12, ObservationTtlMinutes: 10, ActivityRetentionDays: 30, CompletedJobRetentionDays: 7, }, Users: []*User{}, Servers: []*Server{}, Agents: []*Agent{}, EnrollmentTokens: []*EnrollmentToken{}, Customers: []*Customer{}, Nodes: []*Node{}, NodeClients: []*NodeClient{}, Jobs: []*Job{}, Activity: []*Activity{}, Observations: []*Observation{}, SubscriptionAccess: []*SubscriptionAccess{}, SubscriptionClients: []*SubscriptionClient{}, KeyStore: map[string]*KeyEntry{}, } } func settingsMissing(s Settings) bool { return s.BrandName == "" && s.SessionHours == 0 && s.ObservationTtlMinutes == 0 && s.ActivityRetentionDays == 0 && s.CompletedJobRetentionDays == 0 && len(s.Extra) == 0 } // validateData mirrors the JS #validate. func validateData(d *Data) error { if d == nil || d.SchemaVersion != 1 || settingsMissing(d.Settings) { return errors.New("Unsupported or corrupt NexusGate data file") } for _, c := range collections { if c.isNil(d) { return fmt.Errorf("Invalid collection: %s", c.name) } } return nil } // Store is the file-backed database handle. type Store struct { File string Data *Data mu sync.Mutex } // NewStore mirrors the JS constructor (file is stored resolved/absolutized, // like path.resolve). func NewStore(file string) *Store { if abs, err := filepath.Abs(file); err == nil { file = abs } return &Store{File: file} } // Init loads the database file. A missing file seeds EmptyData and persists // it. Parse failures are returned as errors (JS rethrows anything that is // not ENOENT). func (s *Store) Init() error { s.mu.Lock() defer s.mu.Unlock() if err := os.MkdirAll(filepath.Dir(s.File), 0o700); err != nil { return err } raw, err := os.ReadFile(s.File) if err != nil { if !errors.Is(err, os.ErrNotExist) { return err } s.Data = EmptyData() return s.persist(s.Data) } var data Data if err := unmarshalJSONUseNumber(raw, &data); err != nil { return err } if data.SubscriptionAccess == nil { data.SubscriptionAccess = []*SubscriptionAccess{} } if data.SubscriptionClients == nil { data.SubscriptionClients = []*SubscriptionClient{} } if data.KeyStore == nil { data.KeyStore = map[string]*KeyEntry{} } if err := validateData(&data); err != nil { return err } if MigrateCustomerCredentials(&data) { // Persist the converged credential model immediately, so a crash // before the next transaction cannot resurrect per-binding sets. if err := s.persist(&data); err != nil { return err } } s.Data = &data return nil } // MigrateCustomerCredentials converges legacy per-binding credentials into // each customer's single set (round-2 item 2; the 3x-ui model of one // credential row per client). Deterministic rules: // - A customer with a complete stored set keeps it. // - Otherwise the set is copied from the customer's first binding (in // stored order) that carries credentials; a customer with no usable // binding credentials gets a fresh set. // - The relay password must be a base64 32-byte root key, because SS2022 // user keys derive from it by prefix truncation (EffectiveRelayPassword). // If the converged password does not decode to exactly 32 bytes, only // the password is regenerated — clientId and shortId are preserved. // - Every binding's stored credentials are then cleared, so bindings stop // carrying credentials at all. // // Returns true when anything changed. func MigrateCustomerCredentials(d *Data) bool { changed := false for _, customer := range d.Customers { var creds *Credentials if customer.Credentials != nil && customer.Credentials.ClientID != "" { copied := *customer.Credentials creds = &copied } else { for _, nc := range d.NodeClients { if nc.CustomerID == customer.ID && nc.Credentials != nil && nc.Credentials.ClientID != "" { copied := *nc.Credentials creds = &copied break } } } if creds == nil { fresh := NewCustomerCredentials() creds = &fresh } if raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword); err != nil || len(raw) != 32 { creds.RelayPassword = b64(32) } old := customer.Credentials if old == nil || old.ClientID != creds.ClientID || old.RelayPassword != creds.RelayPassword || old.ShortID != creds.ShortID { customer.Credentials = creds changed = true } } for _, nc := range d.NodeClients { if nc.Credentials != nil { nc.Credentials = nil changed = true } } return changed } // Snapshot returns a deep copy of the current data (JS structuredClone). func (s *Store) Snapshot() *Data { s.mu.Lock() defer s.mu.Unlock() if s.Data == nil { return nil } return s.Data.Clone() } // Transaction runs fn against a clone of the data under the store lock. If // fn returns ErrSkip, nothing is persisted and (nil, nil) is returned. If // fn returns any other error, nothing is persisted and the error is returned // as-is. Otherwise (including fn returning (nil, nil), like a JS mutator // returning undefined) the draft is validated, persisted and installed. func (s *Store) Transaction(fn func(d *Data) (any, error)) (any, error) { s.mu.Lock() defer s.mu.Unlock() draft := s.Data.Clone() result, err := fn(draft) if err != nil { if errors.Is(err, ErrSkip) { return nil, nil } return nil, err } if err := validateData(draft); err != nil { return nil, err } if err := s.persist(draft); err != nil { return nil, err } s.Data = draft return result, nil } // Transient runs fn against the live data under the same lock, without // validating or persisting (JS transient). func (s *Store) Transient(fn func(d *Data)) { s.mu.Lock() defer s.mu.Unlock() fn(s.Data) } // View runs fn against the live data under the lock for read-only access. func (s *Store) View(fn func(d *Data)) { s.mu.Lock() defer s.mu.Unlock() fn(s.Data) } // Replace swaps the whole database for next (JS replace): next is cloned, // missing subscription collections / keyStore are defaulted, and the copy is // validated before it lands. func (s *Store) Replace(next *Data) error { if next == nil { return errors.New("Unsupported or corrupt NexusGate data file") } _, err := s.Transaction(func(draft *Data) (any, error) { copy := next.Clone() if copy.SubscriptionAccess == nil { copy.SubscriptionAccess = []*SubscriptionAccess{} } if copy.SubscriptionClients == nil { copy.SubscriptionClients = []*SubscriptionClient{} } if copy.KeyStore == nil { copy.KeyStore = map[string]*KeyEntry{} } if err := validateData(copy); err != nil { return nil, err } *draft = *copy return nil, nil }) return err } // persist writes data atomically: `..tmp`, mode 0600, rename, // chmod 0600. Callers hold s.mu (Init excepted, before publication). func (s *Store) persist(data *Data) error { temp := fmt.Sprintf("%s.%d.tmp", s.File, os.Getpid()) body, err := marshalJSON(data) if err != nil { return err } body = append(body, '\n') if err := os.WriteFile(temp, body, 0o600); err != nil { return err } if err := os.Rename(temp, s.File); err != nil { return err } return os.Chmod(s.File, 0o600) }