feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user