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
296 lines
8.9 KiB
Go
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)
|
|
}
|