Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md.
235 lines
6.7 KiB
Go
235 lines
6.7 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 (
|
|
"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: "NexusGate",
|
|
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
|
|
}
|
|
s.Data = &data
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|