feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s

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:
aaaamoooo
2026-10-20 08:21:54 +00:00
parent abb1a4344b
commit 3951134940
37 changed files with 12183 additions and 0 deletions
+234
View File
@@ -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)
}