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 } } } // Monthly traffic resets run before the quota evaluation below, // so a customer whose new period just started is judged on the // zeroed counters (and a reset can lift a traffic suspension in // the same pass). Due-ness derives from the persisted period // marker, so this also catches up resets missed while the panel // was down — at the first pass after it is back (<=60s). if err := ApplyMonthlyTrafficResets(d, time.Now()); err != nil { return nil, err } 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() // System-status sampling + due external-source refreshes (outside the // transaction; fetches must not hold the store lock). a.monitor().Sample(a.store.Snapshot(), false) a.RefreshDueSources() } // ---------- 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) } } // Node sort order migration (idempotent): unless the stored // sortIndex values are exactly the permutation 0..n-1, renumber by // storage order. Mirrors the Node implementation's boot migration. { nodes := store.Snapshot().Nodes seen := map[int]bool{} valid := true for _, node := range nodes { if node.SortIndex < 0 || node.SortIndex >= len(nodes) || seen[node.SortIndex] { valid = false break } seen[node.SortIndex] = true } if !valid { if _, err := store.Transaction(func(d *Data) (any, error) { for index, node := range d.Nodes { node.SortIndex = index } return nil, nil }); err != nil { return err } } } 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) } }