Files
NexusGate/panel-go/store.go
T
aaaamoooo d316d0f8bb feat: customer-level credentials shared across all nodes (item 2)
One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.

- store: load-time convergence — first binding's set wins; a converged
  password that is not a 32-byte root key is regenerated (uuid/shortId
  preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
  subscriptions resolve the same set; the derivation applies in both the
  inbound builder and the URI builder (verified live: Alice's N1 link is
  byte-identical to the pre-change seed expectation, her N2 user key is
  exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
  regenerate is now POST /api/customers/:id/credentials/regenerate and
  rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
  node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
  SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
  treats a base64 root key's prefix-truncation encodings and the Surge
  '='-mangled variants as the same secret
2026-10-21 11:18:00 +08:00

296 lines
8.9 KiB
Go

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: `<file>.<pid>.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)
}