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,380 @@
|
||||
package main
|
||||
|
||||
// Program entry point: environment, store bootstrap, startup secret
|
||||
// scrubbing, admin bootstrap, HTTP server and the 60s housekeeping loop —
|
||||
// mirroring server.js main() and housekeeping().
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VERSION is the panel version; do not change (agents and the AnyTLS gate
|
||||
// parse it numerically).
|
||||
const VERSION = "0.6.8"
|
||||
|
||||
// cookieSecure mirrors NG_COOKIE_SECURE !== 'false'; set in main().
|
||||
var cookieSecure = true
|
||||
|
||||
// ---------- startup scrub helpers ----------
|
||||
|
||||
func extraStringValue(ex extraFields, key string) (string, bool) {
|
||||
raw, ok := ex[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func setExtraString(ex extraFields, key, value string) extraFields {
|
||||
if ex == nil {
|
||||
ex = extraFields{}
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
ex[key] = raw
|
||||
return ex
|
||||
}
|
||||
|
||||
// extraEngineDetail reads engine.detail when a record carries its engine
|
||||
// state as an unknown (Extra) field rather than the typed field.
|
||||
func extraEngineDetail(ex extraFields) (string, bool) {
|
||||
raw, ok := ex["engine"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var eng map[string]any
|
||||
if err := json.Unmarshal(raw, &eng); err != nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := eng["detail"].(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func setExtraEngineDetail(ex extraFields, value string) {
|
||||
raw, ok := ex["engine"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var eng map[string]any
|
||||
if err := json.Unmarshal(raw, &eng); err != nil {
|
||||
return
|
||||
}
|
||||
eng["detail"] = value
|
||||
out, err := json.Marshal(eng)
|
||||
if err == nil {
|
||||
ex["engine"] = out
|
||||
}
|
||||
}
|
||||
|
||||
func needsScrub(s string) bool {
|
||||
return s != "" && s != RedactSecrets(s)
|
||||
}
|
||||
|
||||
// startupNeedsCleaning reports whether any persisted job/node/server still
|
||||
// holds an un-redacted secret in error/lastError/engine.detail.
|
||||
func startupNeedsCleaning(d *Data) bool {
|
||||
checkExtra := func(ex extraFields) bool {
|
||||
for _, key := range []string{"error", "lastError"} {
|
||||
if s, ok := extraStringValue(ex, key); ok && needsScrub(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if s, ok := extraEngineDetail(ex); ok && needsScrub(s) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, job := range d.Jobs {
|
||||
if job.Error != nil && needsScrub(*job.Error) || checkExtra(job.Extra) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, node := range d.Nodes {
|
||||
if node.Error != nil && needsScrub(*node.Error) || checkExtra(node.Extra) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, server := range d.Servers {
|
||||
if server.Engine != nil && needsScrub(server.Engine.Detail) || checkExtra(server.Extra) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scrubExtra(ex extraFields) {
|
||||
for _, key := range []string{"error", "lastError"} {
|
||||
if s, ok := extraStringValue(ex, key); ok && s != "" {
|
||||
setExtraString(ex, key, RedactSecrets(s))
|
||||
}
|
||||
}
|
||||
if s, ok := extraEngineDetail(ex); ok && s != "" {
|
||||
setExtraEngineDetail(ex, RedactSecrets(s))
|
||||
}
|
||||
}
|
||||
|
||||
func scrubRecordSecrets(d *Data) {
|
||||
for _, job := range d.Jobs {
|
||||
if job.Error != nil && *job.Error != "" {
|
||||
redacted := RedactSecrets(*job.Error)
|
||||
job.Error = &redacted
|
||||
}
|
||||
scrubExtra(job.Extra)
|
||||
}
|
||||
for _, node := range d.Nodes {
|
||||
if node.Error != nil && *node.Error != "" {
|
||||
redacted := RedactSecrets(*node.Error)
|
||||
node.Error = &redacted
|
||||
}
|
||||
scrubExtra(node.Extra)
|
||||
}
|
||||
for _, server := range d.Servers {
|
||||
if server.Engine != nil && server.Engine.Detail != "" {
|
||||
server.Engine.Detail = RedactSecrets(server.Engine.Detail)
|
||||
}
|
||||
scrubExtra(server.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- housekeeping ----------
|
||||
|
||||
// housekeeping is server.js housekeeping(): offline servers, expired job
|
||||
// leases, quota suspensions/resumes, retention pruning, access pruning and
|
||||
// session pruning, every 60 seconds. The transaction result is never SKIP,
|
||||
// so a successful pass persists.
|
||||
func (a *App) housekeeping() {
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
nowISO := NowISO()
|
||||
for _, server := range d.Servers {
|
||||
if server.LastSeenAt == nil {
|
||||
continue
|
||||
}
|
||||
if ms, ok := ParseISOMs(*server.LastSeenAt); ok && now-ms > 180000 {
|
||||
server.Status = "offline"
|
||||
}
|
||||
}
|
||||
for _, job := range d.Jobs {
|
||||
if job.Status != "running" || job.LeaseUntil == nil {
|
||||
continue
|
||||
}
|
||||
ms, ok := ParseISOMs(*job.LeaseUntil)
|
||||
if !ok || ms > now {
|
||||
continue
|
||||
}
|
||||
job.LeaseUntil = nil
|
||||
job.UpdatedAt = nowISO
|
||||
if job.Attempts < 3 {
|
||||
job.Status = "queued"
|
||||
job.Error = StrPtr("Agent 未在租约内确认,任务已自动重试")
|
||||
} else {
|
||||
job.Status = "failed"
|
||||
job.Error = StrPtr("Agent 连续 3 次未在租约内确认")
|
||||
if node := findNode(d, job.NodeID); node != nil {
|
||||
kind := ""
|
||||
if job.Payload != nil {
|
||||
kind = job.Payload.KindOf()
|
||||
}
|
||||
if kind == "node-exit" {
|
||||
node.Status = "degraded"
|
||||
} else {
|
||||
node.Status = "failed"
|
||||
}
|
||||
node.Error = job.Error
|
||||
node.UpdatedAt = nowISO
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, customer := range d.Customers {
|
||||
expired := false
|
||||
if customer.ExpiresAt != nil {
|
||||
if ms, ok := ParseISOMs(*customer.ExpiresAt); ok && ms <= now {
|
||||
expired = true
|
||||
}
|
||||
}
|
||||
exhausted := customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes
|
||||
if (expired || exhausted) && customer.Status == "active" {
|
||||
customer.Status = "suspended"
|
||||
if expired {
|
||||
customer.SuspendReason = StrPtr("expired")
|
||||
customer.SuspendReasonPresent = true
|
||||
} else {
|
||||
customer.SuspendReason = StrPtr("traffic_limit")
|
||||
customer.SuspendReasonPresent = true
|
||||
}
|
||||
customer.UpdatedAt = nowISO
|
||||
if _, err := SuspendCustomerResources(d, customer, "system:quota"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if customer.Status == "suspended" && strVal(customer.SuspendReason) == "ip_limit" && CustomerBlockReason(d, customer) == "" {
|
||||
customer.Status = "active"
|
||||
customer.SuspendReason = nil
|
||||
customer.SuspendReasonPresent = true
|
||||
customer.UpdatedAt = nowISO
|
||||
if _, err := ResumeCustomerResources(d, customer, "system:ip_window"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
jobRetention := d.Settings.CompletedJobRetentionDays
|
||||
if jobRetention == 0 {
|
||||
jobRetention = 7
|
||||
}
|
||||
jobCutoff := now - int64(jobRetention*86400000)
|
||||
jobs := d.Jobs[:0]
|
||||
for _, job := range d.Jobs {
|
||||
if job.Status == "completed" || job.Status == "failed" {
|
||||
if ms, ok := ParseISOMs(job.UpdatedAt); !ok || ms <= jobCutoff {
|
||||
continue
|
||||
}
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
d.Jobs = jobs
|
||||
activityRetention := d.Settings.ActivityRetentionDays
|
||||
if activityRetention == 0 {
|
||||
activityRetention = 30
|
||||
}
|
||||
activityCutoff := now - int64(activityRetention*86400000)
|
||||
activity := d.Activity[:0]
|
||||
for _, event := range d.Activity {
|
||||
if ms, ok := ParseISOMs(event.At); ok && ms > activityCutoff {
|
||||
activity = append(activity, event)
|
||||
}
|
||||
}
|
||||
if len(activity) > 2000 {
|
||||
activity = activity[:2000]
|
||||
}
|
||||
d.Activity = activity
|
||||
PruneAccess(d, time.Now())
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Housekeeping failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
a.getSessions().Prune()
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
|
||||
func defaultDataFile() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return filepath.Join("data", "nexusgate.json")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "data", "nexusgate.json")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
dataFile := os.Getenv("NG_DATA_FILE")
|
||||
if dataFile == "" {
|
||||
dataFile = defaultDataFile()
|
||||
}
|
||||
host := os.Getenv("NG_HOST")
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
port := 8787
|
||||
if p := os.Getenv("NG_PORT"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil {
|
||||
port = n
|
||||
}
|
||||
}
|
||||
cookieSecure = os.Getenv("NG_COOKIE_SECURE") != "false"
|
||||
|
||||
store := NewStore(dataFile)
|
||||
if err := store.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
if startupNeedsCleaning(store.Snapshot()) {
|
||||
if _, err := store.Transaction(func(d *Data) (any, error) {
|
||||
scrubRecordSecrets(d)
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
missingToken := false
|
||||
for _, customer := range store.Snapshot().Customers {
|
||||
if customer.SubscriptionToken == "" {
|
||||
missingToken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if missingToken {
|
||||
if _, err := store.Transaction(func(d *Data) (any, error) {
|
||||
for _, customer := range d.Customers {
|
||||
if customer.SubscriptionToken == "" {
|
||||
customer.SubscriptionToken = RandomToken(32)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(store.Snapshot().Users) == 0 {
|
||||
password := os.Getenv("NG_ADMIN_PASSWORD")
|
||||
generated := false
|
||||
if password == "" {
|
||||
password = RandomToken(15)
|
||||
generated = true
|
||||
}
|
||||
username := os.Getenv("NG_ADMIN_USERNAME")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
if _, err := store.Transaction(func(d *Data) (any, error) {
|
||||
d.Users = append(d.Users, &User{
|
||||
ID: NewID("usr"),
|
||||
Username: username,
|
||||
PasswordHash: HashSecret(password),
|
||||
Role: "owner",
|
||||
Status: "active",
|
||||
CreatedAt: NowISO(),
|
||||
})
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if generated {
|
||||
fmt.Printf("BOOTSTRAP_PASSWORD=%s\n", password)
|
||||
}
|
||||
}
|
||||
|
||||
app := NewApp(store, nil, NewOrchestrator(store))
|
||||
app.replaceSessions(NewSessionManager(app.sessionHours()))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: host + ":" + strconv.Itoa(port),
|
||||
Handler: app,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
app.housekeeping()
|
||||
}
|
||||
}()
|
||||
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user